From ebebff867e17aa32468e749a6923d92d22894645 Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 28 Jul 2019 09:12:03 +0100 Subject: [PATCH 1/8] Add asyncio variant of controller for HTTP devices. --- homekit/accessoryserver.py | 2 +- homekit/aio/__init__.py | 21 + homekit/aio/__main__.py | 466 ++++++++++++++++++++++ homekit/aio/controller/__init__.py | 21 + homekit/aio/controller/controller.py | 217 +++++++++++ homekit/aio/controller/ip/__init__.py | 23 ++ homekit/aio/controller/ip/connection.py | 492 ++++++++++++++++++++++++ homekit/aio/controller/ip/discovery.py | 138 +++++++ homekit/aio/controller/ip/pairing.py | 433 +++++++++++++++++++++ homekit/aio/controller/ip/zeroconf.py | 23 ++ homekit/aio/controller/pairing.py | 129 +++++++ requirements.txt | 1 + requirements_osx.txt | 1 + tests/aio/conftest.py | 171 ++++++++ tests/aio/test_controller.py | 21 + tests/aio/test_ip_discovery.py | 44 +++ tests/aio/test_ip_pairing.py | 257 +++++++++++++ 17 files changed, 2459 insertions(+), 1 deletion(-) create mode 100644 homekit/aio/__init__.py create mode 100644 homekit/aio/__main__.py create mode 100644 homekit/aio/controller/__init__.py create mode 100644 homekit/aio/controller/controller.py create mode 100644 homekit/aio/controller/ip/__init__.py create mode 100644 homekit/aio/controller/ip/connection.py create mode 100644 homekit/aio/controller/ip/discovery.py create mode 100644 homekit/aio/controller/ip/pairing.py create mode 100644 homekit/aio/controller/ip/zeroconf.py create mode 100644 homekit/aio/controller/pairing.py create mode 100644 tests/aio/conftest.py create mode 100644 tests/aio/test_controller.py create mode 100644 tests/aio/test_ip_discovery.py create mode 100644 tests/aio/test_ip_pairing.py diff --git a/homekit/accessoryserver.py b/homekit/accessoryserver.py index f833132b..5481e619 100644 --- a/homekit/accessoryserver.py +++ b/homekit/accessoryserver.py @@ -980,7 +980,7 @@ def _post_pairings(self): # 3) construct response TLV tmp = [(TLV.kTLVType_State, TLV.M2)] - for index, pairing_id in enumerate(server_data.peers): + for index, pairing_id in enumerate(sorted(server_data.peers)): tmp.append((TLV.kTLVType_Identifier, pairing_id.encode())) tmp.append((TLV.kTLVType_PublicKey, server_data.get_peer_key(pairing_id.encode()))) user = TLV.kTLVType_Permission_RegularUser diff --git a/homekit/aio/__init__.py b/homekit/aio/__init__.py new file mode 100644 index 00000000..8f3a134f --- /dev/null +++ b/homekit/aio/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +__all__ = [ + 'Controller' +] + +from .controller import Controller diff --git a/homekit/aio/__main__.py b/homekit/aio/__main__.py new file mode 100644 index 00000000..bb2fad7d --- /dev/null +++ b/homekit/aio/__main__.py @@ -0,0 +1,466 @@ + +import argparse +import asyncio +import json +import locale +import logging +import sys + +from homekit.log_support import setup_logging, add_log_arguments +from homekit.model.characteristics import CharacteristicsTypes +from homekit.model.services import ServicesTypes +from homekit.pair import pin_from_keyboard, pin_from_parameter + +from .controller import Controller + + +logger = logging.getLogger(__name__) + + +def _cancel_all_tasks(loop): + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete( + asyncio.gather(*to_cancel, loop=loop, return_exceptions=True)) + + for task in to_cancel: + if task.cancelled(): + continue + try: + task.result() + except Exception: + logging.exception("Error during shutdown") + + +def run(main, debug=False): + """Runs a coroutine and returns the result. + + asyncio.run was added in python 3.7. This is broadly the same and can be + removed when we no longer support 3.6. + """ + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + loop.set_debug(debug) + return loop.run_until_complete(main) + finally: + try: + _cancel_all_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def prepare_string(input_string): + """ + Make a string save for printing in a terminal. The string get recoded using the terminals preferred locale and + replacing the characters that cannot be encoded. + :param input_string: the input string + :return: the output string which is save for printing + """ + return '{t}'.format(t=input_string.encode(locale.getpreferredencoding(), errors='replace').decode()) + + +async def discover_ip(args): + controller = Controller() + for discovery in await controller.discover_ip(args.timeout): + info = discovery.info + + if args.unpaired_only and info['sf'] == '0': + continue + + print('Name: {name}'.format(name=prepare_string(info['name']))) + print('Url: http_impl://{ip}:{port}'.format(ip=info['address'], port=info['port'])) + print('Configuration number (c#): {conf}'.format(conf=info['c#'])) + print('Feature Flags (ff): {f} (Flag: {flags})'.format(f=info['flags'], flags=info['ff'])) + print('Device ID (id): {id}'.format(id=info['id'])) + print('Model Name (md): {md}'.format(md=prepare_string(info['md']))) + print('Protocol Version (pv): {pv}'.format(pv=info['pv'])) + print('State Number (s#): {sn}'.format(sn=info['s#'])) + print('Status Flags (sf): {sf} (Flag: {flags})'.format(sf=info['statusflags'], flags=info['sf'])) + print('Category Identifier (ci): {c} (Id: {ci})'.format(c=info['category'], ci=info['ci'])) + print() + + return True + + +async def pair_ip(args): + controller = Controller() + + try: + controller.load_data(args.file) + except Exception: + logger.exception("Error while loading {args.file}".format(args=args)) + return False + + if args.alias in controller.get_pairings(): + print('"{a}" is a already known alias'.format(a=args.alias)) + return False + + if args.pin: + pin_function = pin_from_parameter(args.pin) + else: + pin_function = pin_from_keyboard() + + discovery = await controller.find_ip_by_device_id(args.device) + + try: + finish_pairing = await discovery.start_pairing(args.alias) + pairing = await finish_pairing(pin_function()) + await pairing.list_accessories_and_characteristics() + controller.save_data(args.file) + print('Pairing for "{a}" was established.'.format(a=args.alias)) + except Exception: + logging.exception("Error whilst pairing") + return False + + return True + + +async def get_accessories(args): + controller = Controller() + + try: + controller.load_data(args.file) + except Exception: + logger.exception("Error while loading {args.file}".format(args=args)) + return False + + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + try: + pairing = controller.get_pairings()[args.alias] + data = await pairing.list_accessories_and_characteristics() + controller.save_data(args.file) + except Exception: + logging.exception("Error whilst fetching /accessories") + return False + + # prepare output + if args.output == 'json': + print(json.dumps(data, indent=4)) + elif args.output == 'compact': + for accessory in data: + aid = accessory['aid'] + for service in accessory['services']: + s_type = service['type'] + s_iid = service['iid'] + print('{aid}.{iid}: >{stype}<'.format(aid=aid, iid=s_iid, stype=ServicesTypes.get_short(s_type))) + + for characteristic in service['characteristics']: + c_iid = characteristic['iid'] + value = characteristic.get('value', '') + c_type = characteristic['type'] + perms = ','.join(characteristic['perms']) + desc = characteristic.get('description', '') + c_type = CharacteristicsTypes.get_short(c_type) + print(' {aid}.{iid}: {value} ({description}) >{ctype}< [{perms}]'.format(aid=aid, + iid=c_iid, + value=value, + ctype=c_type, + perms=perms, + description=desc)) + return True + + +async def get_characteristics(args): + controller = Controller() + + controller.load_data(args.file) + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + pairing = controller.get_pairings()[args.alias] + + # convert the command line parameters to the required form + characteristics = [(int(c.split('.')[0]), int(c.split('.')[1])) for c in args.characteristics] + + # get the data + try: + data = await pairing.get_characteristics( + characteristics, + include_meta=args.meta, + include_perms=args.perms, + include_type=args.type, + include_events=args.events + ) + except Exception: + logging.exception("Error whilst fetching /accessories") + return False + + # print the data + tmp = {} + for k in data: + nk = str(k[0]) + '.' + str(k[1]) + tmp[nk] = data[k] + + print(json.dumps(tmp, indent=4)) + return True + + +async def put_characteristics(args): + controller = Controller(args.adapter) + try: + controller.load_data(args.file) + except Exception: + logger.exception("Error whilst loading pairing") + return False + + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + try: + pairing = controller.get_pairings()[args.alias] + + characteristics = [(int(c[0].split('.')[0]), # the first part is the aid, must be int + int(c[0].split('.')[1]), # the second part is the iid, must be int + c[1]) for c in args.characteristics] + results = await pairing.put_characteristics(characteristics, do_conversion=True) + except Exception: + logging.exception("Unhandled error whilst writing to device") + return False + + for key, value in results.items(): + aid = key[0] + iid = key[1] + status = value['status'] + desc = value['description'] + # used to be < 0 but bluetooth le errors are > 0 and only success (= 0) needs to be checked + if status != 0: + print('put_characteristics failed on {aid}.{iid} because: {reason} ({code})'.format(aid=aid, iid=iid, + reason=desc, + code=status)) + return True + + +async def list_pairings(args): + controller = Controller(args.adapter) + controller.load_data(args.file) + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + exit(-1) + + pairing = controller.get_pairings()[args.alias] + try: + pairings = await pairing.list_pairings() + except Exception as e: + print(e) + logging.debug(e, exc_info=True) + sys.exit(-1) + + for pairing in pairings: + print('Pairing Id: {id}'.format(id=pairing['pairingId'])) + print('\tPublic Key: 0x{key}'.format(key=pairing['publicKey'])) + print('\tPermissions: {perm} ({type})'.format(perm=pairing['permissions'], + type=pairing['controllerType'])) + + return True + + +async def remove_pairing(args): + controller = Controller(args.adapter) + controller.load_data(args.file) + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + pairing = controller.get_pairings()[args.alias] + await pairing.remove_pairing(args.controllerPairingId) + controller.save_data(args.file) + print('Pairing for "{a}" was removed.'.format(a=args.alias)) + return True + + +async def unpair(args): + controller = Controller(args.adapter) + controller.load_data(args.file) + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + await controller.remove_pairing(args.alias) + controller.save_data(args.file) + print('Device was completely unpaired.'.format(a=args.alias)) + return True + + +async def get_events(args): + controller = Controller() + + controller.load_data(args.file) + if args.alias not in controller.get_pairings(): + print('"{a}" is no known alias'.format(a=args.alias)) + return False + + pairing = controller.get_pairings()[args.alias] + + # convert the command line parameters to the required form + characteristics = [(int(c.split('.')[0]), int(c.split('.')[1])) for c in args.characteristics] + + def handler(data): + # print the data + tmp = {} + for k in data: + nk = str(k[0]) + '.' + str(k[1]) + tmp[nk] = data[k] + + print(json.dumps(tmp, indent=4)) + + pairing.dispatcher_connect(handler) + + results = await pairing.subscribe(characteristics) + if results: + for key, value in results.items(): + aid = key[0] + iid = key[1] + status = value['status'] + desc = value['description'] + if status < 0: + print('watch failed on {aid}.{iid} because: {reason} ({code})'.format( + aid=aid, + iid=iid, + reason=desc, + code=status + )) + return False + + while True: + # get the data + try: + data = await pairing.get_characteristics( + characteristics, + ) + handler(data) + except Exception: + logging.exception("Error whilst fetching /accessories") + return False + + await asyncio.sleep(10) + + return True + + +def setup_parser_for_pairing(parser): + parser.add_argument('-f', action='store', required=True, dest='file', help='File with the pairing data') + parser.add_argument('-a', action='store', required=True, dest='alias', help='alias for the pairing') + + +async def main(argv=None): + argv = argv or sys.argv[1:] + + parser = argparse.ArgumentParser() + parser.add_argument('--adapter', action='store', dest='adapter', default='hci0', + help='the bluetooth adapter to be used (defaults to hci0)') + add_log_arguments(parser) + + subparsers = parser.add_subparsers( + title='subcommands', + description='valid subcommands', + help='additional help' + ) + + # discover_ip + discover_parser = subparsers.add_parser('discover_ip') + discover_parser.set_defaults(func=discover_ip) + discover_parser.add_argument( + '-t', action='store', required=False, dest='timeout', type=int, default=10, + help='Number of seconds to wait') + discover_parser.add_argument( + '-u', action='store_true', required=False, dest='unpaired_only', + help='If activated, this option will show only unpaired HomeKit IP Devices') + + # pair_ip + pair_parser = subparsers.add_parser('pair_ip') + pair_parser.set_defaults(func=pair_ip) + setup_parser_for_pairing(pair_parser) + pair_parser.add_argument( + '-d', action='store', required=True, dest='device', + help='HomeKit Device ID (use discover to get it)') + pair_parser.add_argument( + '-p', action='store', required=False, dest='pin', help='HomeKit configuration code') + + # get_accessories - return all characteristics of all services of all accessories. + get_accessories_parser = subparsers.add_parser('get_accessories') + get_accessories_parser.set_defaults(func=get_accessories) + setup_parser_for_pairing(get_accessories_parser) + get_accessories_parser.add_argument( + '-o', action='store', dest='output', default='compact', + choices=['json', 'compact'], help='Specify output format') + + # get_characteristics - get only requested characteristics + get_char_parser = subparsers.add_parser('get_characteristics') + get_char_parser.set_defaults(func=get_characteristics) + setup_parser_for_pairing(get_char_parser) + get_char_parser.add_argument( + '-c', action='append', required=True, dest='characteristics', + help='Read characteristics, multiple characteristics can be given by repeating the option') + get_char_parser.add_argument( + '-m', action='store_true', required=False, dest='meta', + help='read out the meta data for the characteristics as well') + get_char_parser.add_argument( + '-p', action='store_true', required=False, dest='perms', + help='read out the permissions for the characteristics as well') + get_char_parser.add_argument( + '-t', action='store_true', required=False, dest='type', + help='read out the types for the characteristics as well') + get_char_parser.add_argument( + '-e', action='store_true', required=False, dest='events', + help='read out the events for the characteristics as well') + + # put_characteristics - set characteristics values + put_char_parser = subparsers.add_parser('put_characteristics') + put_char_parser.set_defaults(func=put_characteristics) + setup_parser_for_pairing(put_char_parser) + put_char_parser.add_argument( + '-c', action='append', required=False, dest='characteristics', nargs=2, + help='Use aid.iid value to change the value. Repeat to change multiple characteristics.') + + # list_pairings - list all pairings + list_pairings_parser = subparsers.add_parser('list_pairings') + list_pairings_parser.set_defaults(func=list_pairings) + setup_parser_for_pairing(list_pairings_parser) + + # remove_pairing - remove sub pairing + remove_pairing_parser = subparsers.add_parser('remove_pairing') + remove_pairing_parser.set_defaults(func=remove_pairing) + setup_parser_for_pairing(remove_pairing_parser) + remove_pairing_parser.add_argument( + '-i', action='store', required=True, dest='controllerPairingId', + help='this pairing ID identifies the controller who should be removed from accessory') + + # unpair - completely unpair the device + unpair_parser = subparsers.add_parser('unpair') + unpair_parser.set_defaults(func=unpair) + setup_parser_for_pairing(unpair_parser) + + get_events_parser = subparsers.add_parser('get_events') + get_events_parser.set_defaults(func=get_events) + setup_parser_for_pairing(get_events_parser) + get_events_parser.add_argument( + '-c', action='append', required=True, dest='characteristics', + help='Read characteristics, multiple characteristics can be given by repeating the option') + + args = parser.parse_args(argv) + + setup_logging(args.loglevel) + + if not await args.func(args): + sys.exit(1) + + +if __name__ == "__main__": + try: + run(main()) + except KeyboardInterrupt: + pass diff --git a/homekit/aio/controller/__init__.py b/homekit/aio/controller/__init__.py new file mode 100644 index 00000000..8f3a134f --- /dev/null +++ b/homekit/aio/controller/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +__all__ = [ + 'Controller' +] + +from .controller import Controller diff --git a/homekit/aio/controller/controller.py b/homekit/aio/controller/controller.py new file mode 100644 index 00000000..b539372b --- /dev/null +++ b/homekit/aio/controller/controller.py @@ -0,0 +1,217 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +from json.decoder import JSONDecodeError +import logging +import re + +from homekit.exceptions import AccessoryNotFoundError, ConfigLoadingError, \ + ConfigSavingError, TransportNotSupportedError, MalformedPinError +from homekit.tools import IP_TRANSPORT_SUPPORTED, BLE_TRANSPORT_SUPPORTED + +if IP_TRANSPORT_SUPPORTED: + from .ip import IpDiscovery, IpPairing + from .ip.zeroconf import async_discover_homekit_devices + + +class Controller(object): + """ + This class represents a HomeKit controller (normally your iPhone or iPad). + """ + + def __init__(self, ble_adapter='hci0'): + """ + Initialize an empty controller. Use 'load_data()' to load the pairing data. + + :param ble_adapter: the bluetooth adapter to be used (defaults to hci0) + """ + self.pairings = {} + self.ble_adapter = ble_adapter + self.logger = logging.getLogger('homekit.controller.Controller') + + async def discover_ip(self, max_seconds=10): + """ + Perform a Bonjour discovery for HomeKit accessory. The discovery will last for the given amount of seconds. The + result will be a list of dicts. The keys of the dicts are: + * name: the Bonjour name of the HomeKit accessory (i.e. Testsensor1._hap._tcp.local.) + * address: the IP address of the accessory + * port: the used port + * c#: the configuration number (required) + * ff / flags: the numerical and human readable version of the feature flags (supports pairing or not, see table + 5-8 page 69) + * id: the accessory's pairing id (required) + * md: the model name of the accessory (required) + * pv: the protocol version + * s#: the current state number (required) + * sf / statusflags: the status flag (see table 5-9 page 70) + * ci / category: the category identifier in numerical and human readable form. For more information see table + 12-3 page 254 or homekit.Categories (required) + + IMPORTANT: + This method will ignore all HomeKit accessories that exist in _hap._tcp domain but fail to have all required + TXT record keys set. + + :param max_seconds: how long should the Bonjour service browser do the discovery (default 10s). See sleep for + more details + :return: a list of dicts as described above + """ + if not IP_TRANSPORT_SUPPORTED: + raise TransportNotSupportedError('IP') + devices = await async_discover_homekit_devices(max_seconds) + tmp = [] + for device in devices: + tmp.append(IpDiscovery(self, device)) + return tmp + + async def find_ip_by_device_id(self, device_id, max_seconds=10): + results = await self.discover_ip(max_seconds=max_seconds) + for result in results: + if result.device_id == device_id: + return result + raise AccessoryNotFoundError('No matching accessory found') + + @staticmethod + async def discover_ble(max_seconds=10, adapter='hci0'): + """ + Perform a Bluetooth LE discovery for HomeKit accessory. It will listen for Bluetooth LE advertisement events + for the given amount of seconds. The result will be a list of dicts. The keys of the dicts are: + * name: the model name of the accessory (required) + * mac: the MAC address of the accessory (required) + * sf / flags: the numerical and human readable version of the status flags (supports pairing or not, see table + 6-32 page 125) + * device_id: the accessory's device id (required) + * acid / category: the category identifier in numerical and human readable form. For more information see table + 12-3 page 254 or homekit.Categories (required) + * gsn: Global State Number, increment on change of any characteristic, overflows at 65535. + * cn: the configuration number (required) + * cv: the compatible version + + :param max_seconds: how long should the Bluetooth LE discovery should be performed (default 10s). See sleep for + more details + :param adapter: the bluetooth adapter to be used (defaults to hci0) + :return: a list of dicts as described above + """ + raise TransportNotSupportedError('BLE') + + async def shutdown(self): + """ + Shuts down the controller by closing all connections that might be held open by the pairings of the controller. + """ + for p in self.pairings: + await self.pairings[p].close() + + def get_pairings(self): + """ + Returns a dict containing all pairings known to the controller. + + :return: the dict maps the aliases to Pairing objects + """ + return self.pairings + + def load_data(self, filename): + """ + Loads the pairing data of the controller from a file. + + :param filename: the file name of the pairing data + :raises ConfigLoadingError: if the config could not be loaded. The reason is given in the message. + """ + try: + with open(filename, 'r') as input_fp: + data = json.load(input_fp) + for pairing_id in data: + + if 'Connection' not in data[pairing_id]: + # This is a pre BLE entry in the file with the pairing data, hence it is for an IP based + # accessory. So we set the connection type (in case save data is used everything will be fine) + # and also issue a warning + data[pairing_id]['Connection'] = 'IP' + self.logger.warning( + 'Loaded pairing for %s with missing connection type. Assume this is IP based.', pairing_id) + + if data[pairing_id]['Connection'] == 'IP': + if not IP_TRANSPORT_SUPPORTED: + raise TransportNotSupportedError('IP') + self.pairings[pairing_id] = IpPairing(data[pairing_id]) + elif data[pairing_id]['Connection'] == 'BLE': + if not BLE_TRANSPORT_SUPPORTED: + raise TransportNotSupportedError('BLE') + raise NotImplementedError('BLE support') + else: + # ignore anything else, issue warning + self.logger.warning('could not load pairing %s of type "%s"', pairing_id, + data[pairing_id]['Connection']) + except PermissionError: + raise ConfigLoadingError('Could not open "{f}" due to missing permissions'.format(f=filename)) + except JSONDecodeError: + raise ConfigLoadingError('Cannot parse "{f}" as JSON file'.format(f=filename)) + except FileNotFoundError: + raise ConfigLoadingError('Could not open "{f}" because it does not exist'.format(f=filename)) + + def save_data(self, filename): + """ + Saves the pairing data of the controller to a file. + + :param filename: the file name of the pairing data + :raises ConfigSavingError: if the config could not be saved. The reason is given in the message. + """ + data = {} + for pairing_id in self.pairings: + # package visibility like in java would be nice here + data[pairing_id] = self.pairings[pairing_id]._get_pairing_data() + try: + with open(filename, 'w') as output_fp: + json.dump(data, output_fp, indent=' ') + except PermissionError: + raise ConfigSavingError('Could not write "{f}" due to missing permissions'.format(f=filename)) + except FileNotFoundError: + raise ConfigSavingError( + 'Could not write "{f}" because it (or the folder) does not exist'.format(f=filename)) + + @staticmethod + def check_pin_format(pin): + """ + Checks the format of the given pin: XXX-XX-XXX with X being a digit from 0 to 9 + + :raises MalformedPinError: if the validation fails + """ + if not re.match(r'^\d\d\d-\d\d-\d\d\d$', pin): + raise MalformedPinError('The pin must be of the following XXX-XX-XXX where X is a digit between 0 and 9.') + + async def remove_pairing(self, alias): + """ + Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the + accessory and the controller. + + Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still + to be paired on the next start of the application. + + :param alias: the controller's alias for the accessory + :raises AuthenticationError: if the controller isn't authenticated to the accessory. + :raises AccessoryNotFoundError: if the device can not be found via zeroconf + :raises UnknownError: on unknown errors + """ + if alias not in self.pairings: + raise AccessoryNotFoundError('Alias "{a}" is not found.'.format(a=alias)) + + pairing = self.pairings[alias] + + primary_pairing_id = pairing.pairing_data['iOSPairingId'] + await pairing.remove_pairing(primary_pairing_id) + + await pairing.close() + + del self.pairings[alias] diff --git a/homekit/aio/controller/ip/__init__.py b/homekit/aio/controller/ip/__init__.py new file mode 100644 index 00000000..59aae524 --- /dev/null +++ b/homekit/aio/controller/ip/__init__.py @@ -0,0 +1,23 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .discovery import IpDiscovery +from .pairing import IpPairing + +__all__ = [ + 'IpDiscovery', + 'IpPairing', +] diff --git a/homekit/aio/controller/ip/connection.py b/homekit/aio/controller/ip/connection.py new file mode 100644 index 00000000..b2b39625 --- /dev/null +++ b/homekit/aio/controller/ip/connection.py @@ -0,0 +1,492 @@ +# -*- coding: UTF-8 -*- + +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +import json +import logging + +from homekit.crypto.chacha20poly1305 import chacha20_aead_encrypt, chacha20_aead_decrypt +from homekit.exceptions import AccessoryDisconnectedError +from homekit.http_impl import HttpContentTypes +from homekit.http_impl.response import HttpResponse +from homekit.protocol import get_session_keys +from homekit.protocol.tlv import TLV + + +logger = logging.getLogger(__name__) + + +class InsecureHomeKitProtocol(asyncio.Protocol): + + def __init__(self, connection): + self.connection = connection + self.host = ':'.join((connection.host, str(connection.port))) + self.result_cbs = [] + self.current_response = HttpResponse() + + def connection_made(self, transport): + super().connection_made(transport) + self.transport = transport + + def connection_lost(self, exception): + self.connection._connection_lost(exception) + + async def send_bytes(self, payload): + if self.transport.is_closing(): + # FIXME: It would be nice to try and wait for the reconnect in future. + # In that case we need to make sure we do it at a layer above send_bytes otherwise + # we might encrypt payloads with the last sessions keys then wait for a new connection + # to send them - and on that connection the keys would be different. + # Also need to make sure that the new connection has chance to pair-verify before + # queued writes can happy. + raise AccessoryDisconnectedError('Transport is closed') + + self.transport.write(payload) + + # We return a future so that our caller can block on a reply + # We can send many requests and dispatch the results in order + # Should mean we don't need locking around request/reply cycles + result = asyncio.Future() + self.result_cbs.append(result) + + try: + return await asyncio.wait_for(result, 10) + except asyncio.TimeoutError: + self.transport.write_eof() + self.transport.close() + raise AccessoryDisconnectedError("Timeout while waiting for response") + + def data_received(self, data): + while data: + data = self.current_response.parse(data) + + if self.current_response.is_read_completely(): + http_name = self.current_response.get_http_name().lower() + if http_name == 'http': + next_callback = self.result_cbs.pop(0) + next_callback.set_result(self.current_response) + elif http_name == 'event': + self.connection.event_received(self.current_response) + else: + raise RuntimeError('Unknown http type') + + self.current_response = HttpResponse() + + def eof_received(self): + self.close() + return False + + def close(self): + # If the connection is closed then any pending callbacks will never + # fire, so set them to an error state. + while self.result_cbs: + result = self.result_cbs.pop(0) + result.set_exception(AccessoryDisconnectedError('Connection closed')) + + +class SecureHomeKitProtocol(InsecureHomeKitProtocol): + + def __init__(self, connection, a2c_key, c2a_key): + super().__init__(connection) + + self._incoming_buffer = bytearray() + + self.c2a_counter = 0 + self.a2c_counter = 0 + + self.a2c_key = a2c_key + self.c2a_key = c2a_key + + async def send_bytes(self, payload): + buffer = b'' + + while len(payload) > 0: + current = payload[:1024] + payload = payload[1024:] + + len_bytes = len(current).to_bytes(2, byteorder='little') + cnt_bytes = self.c2a_counter.to_bytes(8, byteorder='little') + self.c2a_counter += 1 + + data, tag = chacha20_aead_encrypt( + len_bytes, + self.c2a_key, + cnt_bytes, + bytes([0, 0, 0, 0]), + current, + ) + + buffer += len_bytes + data + tag + + return await super().send_bytes(buffer) + + def data_received(self, data): + """ + Called by asyncio when data is received from a TCP socket. + + This just handles decryption of 1024 blocks and its them over to the underlying + InsecureHomeKitProtocol to handle HTTP unframing. + + The blocks are expected to be in order - there is no protocol level support for + interleaving of HTTP messages. + """ + + self._incoming_buffer += data + + while len(self._incoming_buffer) >= 2: + block_length_bytes = self._incoming_buffer[:2] + block_length = int.from_bytes(block_length_bytes, 'little') + exp_length = block_length + 18 + + if len(self._incoming_buffer) < exp_length: + # Not enough data yet + return + + # Drop the length from the top of the buffer as we have already parsed it + del self._incoming_buffer[:2] + + block = self._incoming_buffer[:block_length] + del self._incoming_buffer[:block_length] + tag = self._incoming_buffer[:16] + del self._incoming_buffer[:16] + + decrypted = chacha20_aead_decrypt( + block_length_bytes, + self.a2c_key, + self.a2c_counter.to_bytes(8, byteorder='little'), + bytes([0, 0, 0, 0]), + block + tag + ) + + if decrypted is False: + # FIXME: Does raising here drop the connection or do we call close on transport ourselves + raise RuntimeError('Could not decrypt block') + + self.a2c_counter += 1 + + super().data_received(decrypted) + + +class HomeKitConnection: + + def __init__(self, owner, host, port, auto_reconnect=True): + self.owner = owner + self.host = host + self.port = port + self.auto_reconnect = auto_reconnect + + self.when_connected = asyncio.Future() + self.closing = False + self.closed = False + self._retry_interval = 0.5 + + self.transport = None + self.protocol = None + + # FIXME: Assume auto-reconnecting? If you are using the asyncio its probably because + # you are running some kind of long running service, so none auto-reconnecting doesnt make + # sense + + @classmethod + async def connect(cls, *args, **kwargs): + connection = cls(*args, **kwargs) + + if connection.auto_reconnect: + await connection._reconnect() + else: + await connection._connect_once() + + return connection + + async def get(self, target): + """ + Sends a HTTP POST request to the current transport and returns an awaitable + that can be used to wait for a response. + """ + return await self.request( + method='GET', + target=target, + ) + + async def get_json(self, target): + response = await self.get(target) + body = response.body.decode('utf-8') + return json.loads(body) + + async def put(self, target, body, content_type=HttpContentTypes.JSON): + """ + Sends a HTTP POST request to the current transport and returns an awaitable + that can be used to wait for a response. + """ + return await self.request( + method='PUT', + target=target, + headers=[ + ('Content-Type', content_type), + ('Content-Length', len(body)), + ], + body=body, + ) + + async def put_json(self, target, body): + response = await self.put( + target, + json.dumps(body).encode('utf-8'), + content_type=HttpContentTypes.TLV, + ) + + if response.code != 204: + # FIXME: ... + pass + + decoded = response.body.decode('utf-8') + + if not decoded: + # FIXME: Verify this is correct + return {} + + try: + parsed = json.loads(decoded) + except json.JSONDecodeError: + self.transport.close() + raise AccessoryDisconnectedError("Session closed after receiving malformed response from device") + + return parsed + + async def post(self, target, body, content_type=HttpContentTypes.TLV): + """ + Sends a HTTP POST request to the current transport and returns an awaitable + that can be used to wait for a response. + """ + return await self.request( + method='POST', + target=target, + headers=[ + ('Content-Type', content_type), + ('Content-Length', len(body)), + ], + body=body, + ) + + async def post_json(self, target, body): + response = await self.post( + target, + json.dumps(body).encode('utf-8'), + content_type=HttpContentTypes.TLV, + ) + + if response.code != 204: + # FIXME: ... + pass + + decoded = response.body.decode('utf-8') + + if not decoded: + # FIXME: Verify this is correct + return {} + + try: + parsed = json.loads(decoded) + except json.JSONDecodeError: + self.transport.close() + raise AccessoryDisconnectedError("Session closed after receiving malformed response from device") + + return parsed + + async def post_tlv(self, target, body, expected=None): + response = await self.post( + target, + TLV.encode_list(body), + content_type=HttpContentTypes.TLV, + ) + body = TLV.decode_bytes(response.body, expected=expected) + return body + + async def request(self, method, target, headers=None, body=None): + """ + Sends a HTTP request to the current transport and returns an awaitable + that can be used to wait for the response. + + This will automatically set the header. + + :param method: A HTTP method, like 'GET' or 'POST' + :param target: A URI to call the method on + :param headers: a list of (header, value) tuples (optional) + :param body: The body of the request (optional) + """ + buffer = [] + buffer.append('{method} {target} HTTP/1.1'.format( + method=method.upper(), + target=target, + )) + + # WARNING: It is vital that a Host: header is present or some devices + # will reject the request. + buffer.append('Host: {host}'.format(host=self.host)) + + if headers: + for (header, value) in headers: + buffer.append('{header}: {value}'.format( + header=header, + value=value + )) + + buffer.append('') + buffer.append('') + + # WARNING: We use \r\n explicitly. \n is not enough for some. + request_bytes = '\r\n'.join(buffer).encode('utf-8') + + if body: + request_bytes += body + + # WARNING: It is vital that each request is sent in one call + # Some devices are sensitive to unecrypted HTTP requests made in + # multiple packets. + + # https://github.com/jlusiardi/homekit_python/issues/12 + # https://github.com/jlusiardi/homekit_python/issues/16 + + return await self.protocol.send_bytes(request_bytes) + + @property + def is_secure(self): + if not self.protocol: + return False + return isinstance(self.protocol, SecureHomeKitProtocol) + + def close(self): + """ + Close the connection transport. + """ + self.closing = True + + if self.transport: + self.transport.close() + + def _connection_lost(self, exception): + """ + Called by a Protocol instance when eof_received happens. + """ + logger.info("Connection %r lost.", self) + + if not self.when_connected.done(): + self.when_connected.set_exception( + AccessoryDisconnectedError( + 'Current connection attempt failed and will be retried', + ) + ) + + self.when_connected = asyncio.Future() + + if self.auto_reconnect and not self.closing: + asyncio.ensure_future(self._reconnect()) + + if self.closing or not self.auto_reconnect: + self.closed = True + + async def _connect_once(self): + loop = asyncio.get_event_loop() + self.transport, self.protocol = await loop.create_connection( + lambda: InsecureHomeKitProtocol(self), + self.host, + self.port + ) + + if self.owner: + await self.owner.connection_made(False) + + async def _reconnect(self): + # FIXME: How to integrate discovery here? + # There is aiozeroconf but that doesn't work on Windows until python 3.9 + # In HASS, zeroconf is a service provided by HASS itself and want to be able to + # leverage that instead. + while not self.closing: + try: + await self._connect_once() + except OSError: + interval = self._retry_interval = min(60, 1.5 * self._retry_interval) + logger.info("Connecting to accessory failed. Retrying in %i seconds", interval) + await asyncio.sleep(interval) + continue + + self._retry_interval = 0.5 + self.when_connected.set_result(None) + return + + def event_received(self, event): + if not self.owner: + return + + # FIXME: Should drop the connection if can't parse the event? + + decoded = event.body.decode('utf-8') + if not decoded: + return + + try: + parsed = json.loads(decoded) + except json.JSONDecodeError: + return + + self.owner.event_received(parsed) + + def __repr__(self): + return "HomeKitConnection(host=%r, port=%r)" % (self.host, self.port) + + +class SecureHomeKitConnection(HomeKitConnection): + + def __init__(self, owner, pairing_data): + super().__init__( + owner, + pairing_data['AccessoryIP'], + pairing_data['AccessoryPort'], + ) + self.pairing_data = pairing_data + + async def _connect_once(self): + await super()._connect_once() + + state_machine = get_session_keys(self.pairing_data) + + request, expected = state_machine.send(None) + while True: + try: + response = await self.post_tlv( + '/pair-verify', + body=request, + expected=expected, + ) + request, expected = state_machine.send(response) + except StopIteration as result: + # If the state machine raises a StopIteration then we have session keys + c2a_key, a2c_key = result.value + break + + # Secure session has been negotiated - switch protocol so all future messages are encrypted + self.protocol = SecureHomeKitProtocol( + self, + a2c_key, + c2a_key, + ) + self.transport.set_protocol(self.protocol) + self.protocol.connection_made(self.transport) + + if self.owner: + await self.owner.connection_made(True) diff --git a/homekit/aio/controller/ip/discovery.py b/homekit/aio/controller/ip/discovery.py new file mode 100644 index 00000000..7e3dc174 --- /dev/null +++ b/homekit/aio/controller/ip/discovery.py @@ -0,0 +1,138 @@ +# +# Copyright 2019 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +import uuid + +from homekit.protocol import perform_pair_setup_part1, perform_pair_setup_part2 +from homekit.protocol.statuscodes import HapStatusCodes +from homekit.exceptions import AlreadyPairedError + +from .connection import HomeKitConnection +from .pairing import IpPairing + + +class IpDiscovery(object): + + """ + A discovered IP HAP device that is unpaired. + """ + + def __init__(self, controller, discovery_data): + self.controller = controller + self.host = discovery_data['address'] + self.port = discovery_data['port'] + self.device_id = discovery_data['id'] + self.info = discovery_data + + self.connection = None + self.connect_lock = asyncio.Lock() + + def __repr__(self): + return "IPDiscovery(host={self.host}, port={self.port})".format(self=self) + + async def _ensure_connected(self): + if not self.connection: + async with self.connect_lock: + if not self.connection: + self.connection = await HomeKitConnection.connect( + None, + self.host, + self.port, + ) + + await self.connection.when_connected + + async def close(self): + """ + Close the pairing's communications. This closes the session. + """ + if self.connection: + self.connection.close() + self.connection = None + + async def perform_pairing(self, alias, pin): + self.controller.check_pin_format(pin) + finish_pairing = await self.start_pairing(alias) + return await finish_pairing(pin) + + async def start_pairing(self, alias): + await self._ensure_connected() + + state_machine = perform_pair_setup_part1() + request, expected = state_machine.send(None) + while True: + try: + response = await self.connection.post_tlv( + '/pair-setup', + body=request, + expected=expected, + ) + request, expected = state_machine.send(response) + except StopIteration as result: + # If the state machine raises a StopIteration then we have XXX + salt, pub_key = result.value + break + + async def finish_pairing(pin): + self.controller.check_pin_format(pin) + + state_machine = perform_pair_setup_part2(pin, str(uuid.uuid4()), salt, pub_key) + request, expected = state_machine.send(None) + + while True: + try: + response = await self.connection.post_tlv( + '/pair-setup', + body=request, + expected=expected, + ) + request, expected = state_machine.send(response) + except StopIteration as result: + # If the state machine raises a StopIteration then we have XXX + pairing = result.value + break + + pairing['AccessoryIP'] = self.host + pairing['AccessoryPort'] = self.port + pairing['Connection'] = 'IP' + + obj = self.controller.pairings[alias] = IpPairing(pairing) + + self.connection.close() + + return obj + + return finish_pairing + + async def identify(self): + await self._ensure_connected() + + response = await self.connection.post_json('/identify', {}) + + if not response: + return True + + code = response['code'] + + raise AlreadyPairedError( + 'Identify failed because: {reason} ({code}).'.format( + reason=HapStatusCodes[code], + code=code, + ) + ) + + return True diff --git a/homekit/aio/controller/ip/pairing.py b/homekit/aio/controller/ip/pairing.py new file mode 100644 index 00000000..620045ca --- /dev/null +++ b/homekit/aio/controller/ip/pairing.py @@ -0,0 +1,433 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +import logging + +from homekit.controller.tools import check_convert_value +from homekit.protocol.statuscodes import HapStatusCodes +from homekit.exceptions import AccessoryDisconnectedError, AuthenticationError, UnknownError, UnpairedError +from homekit.protocol import error_handler +from homekit.protocol.tlv import TLV +from homekit.model.characteristics import CharacteristicsTypes +from homekit.model.services import ServicesTypes + +from homekit.aio.controller.pairing import AbstractPairing + +from .connection import SecureHomeKitConnection + + +logger = logging.getLogger(__name__) + + +def format_characteristic_list(data): + tmp = {} + for c in data['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 + + +class IpPairing(AbstractPairing): + """ + This represents a paired HomeKit IP accessory. + """ + + def __init__(self, pairing_data): + """ + Initialize a Pairing by using the data either loaded from file or obtained after calling + Controller.perform_pairing(). + + :param pairing_data: + """ + self.pairing_data = pairing_data + self.connection = None + self.connect_lock = asyncio.Lock() + self.subscriptions = set() + + self.listeners = set() + + def event_received(self, event): + event = format_characteristic_list(event) + + for listener in self.listeners: + try: + listener(event) + except Exception: + logger.exception("Unhandled error when processing event") + + async def connection_made(self, secure): + if not secure: + return + + if self.subscriptions: + await self.subscribe(self.subscriptions) + + async def _ensure_connected(self): + if not self.connection: + async with self.connect_lock: + if not self.connection: + self.connection = await SecureHomeKitConnection.connect(self, self.pairing_data) + + await self.connection.when_connected + + async def close(self): + """ + Close the pairing's communications. This closes the session. + """ + if self.connection: + self.connection.close() + self.connection = None + + await asyncio.sleep(0) + + async def list_accessories_and_characteristics(self): + """ + This retrieves a current set of accessories and characteristics behind this pairing. + + :return: the accessory data as described in the spec on page 73 and following + :raises AccessoryNotFoundError: if the device can not be found via zeroconf + """ + await self._ensure_connected() + + response = await self.connection.get_json('/accessories') + + accessories = response['accessories'] + + for accessory in accessories: + for service in accessory['services']: + service['type'] = service['type'].upper() + try: + service['type'] = ServicesTypes.get_uuid(service['type']) + except KeyError: + pass + + for characteristic in service['characteristics']: + characteristic['type'] = characteristic['type'].upper() + try: + characteristic['type'] = CharacteristicsTypes.get_uuid(characteristic['type']) + except KeyError: + pass + + self.pairing_data['accessories'] = accessories + return accessories + + async def list_pairings(self): + """ + This method returns all pairings of a HomeKit accessory. This always includes the local controller and can only + be done by an admin controller. + + The keys in the resulting dicts are: + * pairingId: the pairing id of the controller + * publicKey: the ED25519 long-term public key of the controller + * permissions: bit value for the permissions + * controllerType: either admin or regular + + :return: a list of dicts + :raises: UnknownError: if it receives unexpected data + :raises: UnpairedError: if the polled accessory is not paired + """ + await self._ensure_connected() + + data = await self.connection.post_tlv('/pairings', [ + (TLV.kTLVType_State, TLV.M1), + (TLV.kTLVType_Method, TLV.ListPairings) + ]) + + if not (data[0][0] == TLV.kTLVType_State and data[0][1] == TLV.M2): + raise UnknownError('unexpected data received: ' + str(data)) + elif data[1][0] == TLV.kTLVType_Error and data[1][1] == TLV.kTLVError_Authentication: + raise UnpairedError('Must be paired') + else: + tmp = [] + r = {} + for d in data[1:]: + if d[0] == TLV.kTLVType_Identifier: + r = {} + tmp.append(r) + r['pairingId'] = d[1].decode() + if d[0] == TLV.kTLVType_PublicKey: + r['publicKey'] = d[1].hex() + if d[0] == TLV.kTLVType_Permissions: + controller_type = 'regular' + if d[1] == b'\x01': + controller_type = 'admin' + r['permissions'] = int.from_bytes(d[1], byteorder='little') + r['controllerType'] = controller_type + return tmp + + async 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} + } + """ + await self._ensure_connected() + + if 'accessories' not in self.pairing_data: + await self.list_accessories_and_characteristics() + + 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' + + response = await self.connection.get_json(url) + + return format_characteristic_list(response) + + async def put_characteristics(self, characteristics, do_conversion=False): + """ + 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 + """ + await self._ensure_connected() + + if 'accessories' not in self.pairing_data: + await self.list_accessories_and_characteristics() + + 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_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, 'value': value}) + data = {'characteristics': data} + + response = await self.connection.put_json('/characteristics', data) + if response: + data = {(d['aid'], d['iid']): {'status': d['status'], 'description': HapStatusCodes[d['status']]} for d in + response} + return data + + return {} + + async def subscribe(self, characteristics): + await self._ensure_connected() + self.subscriptions.update(set(characteristics)) + + data = [] + for (aid, iid) in characteristics: + data.append({ + 'aid': aid, + 'iid': iid, + 'ev': True, + }) + + data = {'characteristics': data} + + tmp = {} + + try: + response = await self.connection.put_json('/characteristics', data) + except AccessoryDisconnectedError: + return {} + + if response: + for row in response.get('characteristics', []): + id_tuple = (row['aid'], row['iid']) + tmp[id_tuple] = { + 'status': row['status'], + 'description': HapStatusCodes[row['status']], + } + + return tmp + + async def unsubscribe(self, characteristics): + await self._ensure_connected() + + data = [] + for (aid, iid) in characteristics: + data.append({ + 'aid': aid, + 'iid': iid, + 'ev': False, + }) + + data = {'characteristics': data} + + response = await self.connection.put_json('/characteristics', data) + + char_set = set(characteristics) + tmp = {} + + if response: + for row in response: + id_tuple = (row['aid'], row['iid']) + tmp[id_tuple] = { + 'status': row['status'], + 'description': HapStatusCodes[row['status']], + } + char_set.discard(id_tuple) + + self.subscriptions.difference_update(char_set) + + return tmp + + def dispatcher_connect(self, callback): + """ + Register an event handler to be called when a characteristic (or multiple characteristics) change. + + This function returns immediately. It returns a callable you can use to cancel the subscription. + + The callback is called in the event loop, but should not be a coroutine. + """ + + self.listeners.add(callback) + + def stop_listening(): + self.listeners.discard(callback) + + return stop_listening + + async def identify(self): + """ + This call can be used to trigger the identification of a paired accessory. A successful call should + cause the accessory to perform some specific action by which it can be distinguished from the others (blink a + LED for example). + + It uses the identify characteristic as described on page 152 of the spec. + + :return True, if the identification was run, False otherwise + """ + await self._ensure_connected() + + if 'accessories' not in self.pairing_data: + await self.list_accessories_and_characteristics() + + # we are looking for a characteristic of the identify type + identify_type = CharacteristicsTypes.get_uuid(CharacteristicsTypes.IDENTIFY) + + # search all accessories, all services and all characteristics + for accessory in self.pairing_data['accessories']: + aid = accessory['aid'] + for service in accessory['services']: + for characteristic in service['characteristics']: + iid = characteristic['iid'] + c_type = CharacteristicsTypes.get_uuid(characteristic['type']) + if identify_type == c_type: + # found the identify characteristic, so let's put a value there + if not await self.put_characteristics([(aid, iid, True)]): + return True + return False + + async def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, permissions): + await self._ensure_connected() + + if permissions == 'User': + permissions = TLV.kTLVType_Permission_RegularUser + elif permissions == 'Admin': + permissions = TLV.kTLVType_Permission_AdminUser + else: + raise RuntimeError("Unknown permission: {p}".format(p=permissions)) + + request_tlv = [ + (TLV.kTLVType_State, TLV.M1), + (TLV.kTLVType_Method, TLV.AddPairing), + (TLV.kTLVType_Identifier, additional_controller_pairing_identifier.encode()), + (TLV.kTLVType_PublicKey, bytes.fromhex(ios_device_ltpk)), + (TLV.kTLVType_Permissions, permissions) + ] + + data = await self.connection.post_tlv('/pairings', request_tlv) + + if len(data) == 1 and data[0][1] == TLV.M2: + return True + + # Map TLV error codes to an exception + error_handler(data[0][1], data[1][0]) + + async def remove_pairing(self, pairingId): + """ + Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the + accessory and the controller. + + Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still + to be paired on the next start of the application. + + :param alias: the controller's alias for the accessory + :param pairingId: the pairing id to be removed + :raises AuthenticationError: if the controller isn't authenticated to the accessory. + :raises AccessoryNotFoundError: if the device can not be found via zeroconf + :raises UnknownError: on unknown errors + """ + await self._ensure_connected() + + request_tlv = [ + (TLV.kTLVType_State, TLV.M1), + (TLV.kTLVType_Method, TLV.RemovePairing), + (TLV.kTLVType_Identifier, pairingId.encode('utf-8')) + ] + + data = await self.connection.post_tlv('/pairings', request_tlv) + + # act upon the response (the same is returned for IP and BLE accessories) + # handle the result, spec says, if it has only one entry with state == M2 we unpaired, else its an error. + logging.debug('response data: %s', data) + + if len(data) == 1 and data[0][0] == TLV.kTLVType_State and data[0][1] == TLV.M2: + return True + + self.transport.close() + + if data[1][0] == TLV.kTLVType_Error and data[1][1] == TLV.kTLVError_Authentication: + raise AuthenticationError('Remove pairing failed: missing authentication') + + raise UnknownError('Remove pairing failed: unknown error') diff --git a/homekit/aio/controller/ip/zeroconf.py b/homekit/aio/controller/ip/zeroconf.py new file mode 100644 index 00000000..f1ac2998 --- /dev/null +++ b/homekit/aio/controller/ip/zeroconf.py @@ -0,0 +1,23 @@ +""" +Provide a non-blocking wrapper around the zeroconf library. + +There is aiozercoonf but it doesn't work on Windows - there isn't a +version of asyncio with UDP support on Windows that also supports subprocess. +This is fixed in Python 3.8, but until then it's probably best to stick +with zeroconf. + +This also means we don't need to add any extra dependencies. +""" + +import asyncio +from functools import partial + +from homekit.zeroconf_impl import discover_homekit_devices + + +async def async_discover_homekit_devices(max_seconds=10): + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + partial(discover_homekit_devices, max_seconds=max_seconds) + ) diff --git a/homekit/aio/controller/pairing.py b/homekit/aio/controller/pairing.py new file mode 100644 index 00000000..6cb5e882 --- /dev/null +++ b/homekit/aio/controller/pairing.py @@ -0,0 +1,129 @@ +# +# Copyright 2018 Joachim Lusiardi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import abc + + +class AbstractPairing(abc.ABC): + + def _get_pairing_data(self): + """ + This method returns the internal pairing data. DO NOT mess around with it. + + :return: a dict containing the data + """ + return self.pairing_data + + @abc.abstractmethod + async def close(self): + """ + Close the pairing's communications. This closes the session. + """ + pass + + @abc.abstractmethod + async def list_accessories_and_characteristics(self): + """ + This retrieves a current set of accessories and characteristics behind this pairing. + + :return: the accessory data as described in the spec on page 73 and following + :raises AccessoryNotFoundError: if the device can not be found via zeroconf + """ + pass + + @abc.abstractmethod + async def list_pairings(self): + """ + This method returns all pairings of a HomeKit accessory. This always includes the local controller and can only + be done by an admin controller. + + The keys in the resulting dicts are: + * pairingId: the pairing id of the controller + * publicKey: the ED25519 long-term public key of the controller + * permissions: bit value for the permissions + * controllerType: either admin or regular + + :return: a list of dicts + :raises: UnknownError: if it receives unexpected data + :raises: UnpairedError: if the polled accessory is not paired + """ + pass + + @abc.abstractmethod + async 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} + } + """ + pass + + @abc.abstractmethod + async def put_characteristics(self, characteristics, do_conversion=False): + """ + 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 + """ + pass + + @abc.abstractmethod + async def identify(self): + """ + This call can be used to trigger the identification of a paired accessory. A successful call should + cause the accessory to perform some specific action by which it can be distinguished from the others (blink a + LED for example). + + It uses the identify characteristic as described on page 152 of the spec. + + :return True, if the identification was run, False otherwise + """ + pass + + @abc.abstractmethod + async def remove_pairing(self, pairingId): + """ + Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the + accessory and the controller. + + Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still + to be paired on the next start of the application. + + :param alias: the controller's alias for the accessory + :param pairingId: the pairing id to be removed + :raises AuthenticationError: if the controller isn't authenticated to the accessory. + :raises AccessoryNotFoundError: if the device can not be found via zeroconf + :raises UnknownError: on unknown errors + """ + pass diff --git a/requirements.txt b/requirements.txt index 718ccea8..756c8dfb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ cryptography>=2.5 coverage flake8 pytest +pytest-asyncio diff --git a/requirements_osx.txt b/requirements_osx.txt index 5ed78c7e..ca55d323 100644 --- a/requirements_osx.txt +++ b/requirements_osx.txt @@ -5,3 +5,4 @@ cryptography>=2.5 coverage flake8 pytest +pytest-asyncio diff --git a/tests/aio/conftest.py b/tests/aio/conftest.py new file mode 100644 index 00000000..639fff45 --- /dev/null +++ b/tests/aio/conftest.py @@ -0,0 +1,171 @@ +import errno +import socket +import tempfile +import threading +import time + +import pytest + +from homekit import AccessoryServer +from homekit.model import Accessory +from homekit.model.services import LightBulbService +from homekit.model import mixin as model_mixin +from homekit.aio.controller import Controller +from homekit.aio.controller.ip import IpPairing + + +def port_ready(port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + try: + s.bind(("127.0.0.1", 5555)) + except socket.error as e: + if e.errno == errno.EADDRINUSE: + return True + finally: + s.close() + + return False + + +@pytest.fixture +def controller_and_unpaired_accessory(request, event_loop): + config_file = tempfile.NamedTemporaryFile() + config_file.write("""{ + "accessory_ltpk": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9", + "accessory_ltsk": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a", + "accessory_pairing_id": "12:34:56:00:01:0A", + "accessory_pin": "031-45-154", + "c#": 1, + "category": "Lightbulb", + "host_ip": "127.0.0.1", + "host_port": 51842, + "name": "unittestLight", + "unsuccessful_tries": 0 + }""".encode()) + config_file.flush() + + # Make sure get_id() numbers are stable between tests + model_mixin.id_counter = 0 + + httpd = AccessoryServer(config_file.name, None) + accessory = Accessory('Testlicht', 'lusiardi.de', 'Demoserver', '0001', '0.1') + lightBulbService = LightBulbService() + accessory.services.append(lightBulbService) + httpd.add_accessory(accessory) + + t = threading.Thread(target=httpd.serve_forever) + t.start() + + controller = Controller() + + # This syntax is awkward. We can't use the syntax proposed by the pytest-asyncio + # docs because we have to support python 3.5 + def cleanup(): + async def async_cleanup(): + await controller.shutdown() + event_loop.run_until_complete(async_cleanup()) + request.addfinalizer(cleanup) + + for i in range(10): + if port_ready(51842): + break + time.sleep(1) + + yield controller + + httpd.shutdown() + + t.join() + + +@pytest.fixture +def controller_and_paired_accessory(request, event_loop): + config_file = tempfile.NamedTemporaryFile() + config_file.write("""{ + "accessory_ltpk": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9", + "accessory_ltsk": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a", + "accessory_pairing_id": "12:34:56:00:01:0A", + "accessory_pin": "031-45-154", + "c#": 1, + "category": "Lightbulb", + "host_ip": "127.0.0.1", + "host_port": 51842, + "name": "unittestLight", + "peers": { + "decc6fa3-de3e-41c9-adba-ef7409821bfc": { + "admin": true, + "key": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8" + } + }, + "unsuccessful_tries": 0 + }""".encode()) + config_file.flush() + + # Make sure get_id() numbers are stable between tests + model_mixin.id_counter = 0 + + httpd = AccessoryServer(config_file.name, None) + accessory = Accessory('Testlicht', 'lusiardi.de', 'Demoserver', '0001', '0.1') + lightBulbService = LightBulbService() + accessory.services.append(lightBulbService) + httpd.add_accessory(accessory) + + t = threading.Thread(target=httpd.serve_forever) + t.start() + + controller_file = tempfile.NamedTemporaryFile() + controller_file.write("""{ + "alias": { + "Connection": "IP", + "iOSDeviceLTPK": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8", + "iOSPairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc", + "AccessoryLTPK": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9", + "AccessoryPairingID": "12:34:56:00:01:0A", + "AccessoryPort": 51842, + "AccessoryIP": "127.0.0.1", + "iOSDeviceLTSK": "fa45f082ef87efc6c8c8d043d74084a3ea923a2253e323a7eb9917b4090c2fcc" + } + }""".encode()) + controller_file.flush() + + controller = Controller() + controller.load_data(controller_file.name) + config_file.close() + + # This syntax is awkward. We can't use the syntax proposed by the pytest-asyncio + # docs because we have to support python 3.5 + def cleanup(): + async def async_cleanup(): + await controller.shutdown() + event_loop.run_until_complete(async_cleanup()) + request.addfinalizer(cleanup) + + yield controller + + httpd.shutdown() + + t.join() + + +@pytest.fixture +def pairing(controller_and_paired_accessory): + return controller_and_paired_accessory.get_pairings()['alias'] + + +@pytest.fixture +def pairings(request, event_loop, controller_and_paired_accessory): + """ Returns a pairing of pairngs. """ + left = controller_and_paired_accessory.get_pairings()['alias'] + + right = IpPairing(left.pairing_data) + + # This syntax is awkward. We can't use the syntax proposed by the pytest-asyncio + # docs because we have to support python 3.5 + def cleanup(): + async def async_cleanup(): + await right.close() + event_loop.run_until_complete(async_cleanup()) + request.addfinalizer(cleanup) + + yield (left, right) diff --git a/tests/aio/test_controller.py b/tests/aio/test_controller.py new file mode 100644 index 00000000..a3827a59 --- /dev/null +++ b/tests/aio/test_controller.py @@ -0,0 +1,21 @@ +import pytest + +from homekit.exceptions import AuthenticationError + + +# Without this line you would have to mark your async tests with @pytest.mark.asyncio +pytestmark = pytest.mark.asyncio + + +async def test_remove_pairing(controller_and_paired_accessory): + pairing = controller_and_paired_accessory.pairings['alias'] + + # Verify that there is a pairing connected and working + await pairing.get_characteristics([(1, 10)]) + + # Remove pairing from controller + await controller_and_paired_accessory.remove_pairing('alias') + + # Verify now gives an appropriate error + with pytest.raises(AuthenticationError): + await pairing.get_characteristics([(1, 10)]) diff --git a/tests/aio/test_ip_discovery.py b/tests/aio/test_ip_discovery.py new file mode 100644 index 00000000..a8dee72f --- /dev/null +++ b/tests/aio/test_ip_discovery.py @@ -0,0 +1,44 @@ +import asyncio +import tempfile +import threading +import time + +import pytest + +from homekit import AccessoryServer +from homekit.model import Accessory +from homekit.model.services import LightBulbService +from homekit.model import mixin as model_mixin +from homekit.protocol.tlv import TLV +from homekit.aio.controller import Controller +from homekit.aio.controller.ip import IpDiscovery, IpPairing + + +# Without this line you would have to mark your async tests with @pytest.mark.asyncio +pytestmark = pytest.mark.asyncio + + +async def test_pair(controller_and_unpaired_accessory): + discovery = IpDiscovery(controller_and_unpaired_accessory, { + 'address': '127.0.0.1', + 'port': 51842, + 'id': '00:01:02:03:04:05', + }) + + pairing = await discovery.perform_pairing('alias', '031-45-154') + + assert isinstance(pairing, IpPairing) + + assert await pairing.get_characteristics([(1, 10)]) == { + (1, 10): {'value': False}, + } + +async def test_identify(controller_and_unpaired_accessory): + discovery = IpDiscovery(controller_and_unpaired_accessory, { + 'address': '127.0.0.1', + 'port': 51842, + 'id': '00:01:02:03:04:05', + }) + + identified = await discovery.identify() + assert identified == True diff --git a/tests/aio/test_ip_pairing.py b/tests/aio/test_ip_pairing.py new file mode 100644 index 00000000..b48e9ef0 --- /dev/null +++ b/tests/aio/test_ip_pairing.py @@ -0,0 +1,257 @@ +import asyncio +import tempfile +import threading +import time + +import pytest + +from homekit import AccessoryServer +from homekit.exceptions import AccessoryDisconnectedError +from homekit.model import Accessory +from homekit.model.services import LightBulbService +from homekit.model import mixin as model_mixin +from homekit.protocol.tlv import TLV +from homekit.aio.controller import Controller +from homekit.aio.controller.ip import IpPairing + + +# Without this line you would have to mark your async tests with @pytest.mark.asyncio +pytestmark = pytest.mark.asyncio + + +async def test_list_accessories(pairing): + accessories = await pairing.list_accessories_and_characteristics() + assert accessories[0]['aid'] == 1 + assert accessories[0]['services'][0]['iid'] == 2 + + char = accessories[0]['services'][0]['characteristics'][0] + assert char['iid'] == 3 + assert char['format'] == 'bool' + assert char['perms'] == ['pw'] + assert char['description'] == 'Identify' + assert char['type'] == '00000014-0000-1000-8000-0026BB765291' + + +async def test_get_characteristics(pairing): + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + + assert characteristics[(1, 10)] == {'value': False} + + +async def test_get_characteristics_after_failure(pairing): + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + + assert characteristics[(1, 10)] == {'value': False} + + pairing.connection.transport.close() + + # The connection is closed but the reconnection mechanism hasn't kicked in yet. + # Attempts to use the connection should fail. + with pytest.raises(AccessoryDisconnectedError): + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + + # We can't await a close - this lets the coroutine fall into the 'reactor' + # and process queued work which will include the real transport.close work. + await asyncio.sleep(0) + + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + + assert characteristics[(1, 10)] == {'value': False} + + +async def test_put_characteristics(pairing): + characteristics = await pairing.put_characteristics([ + (1, 10, True), + ]) + + assert characteristics == {} + + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + + assert characteristics[(1, 10)] == {'value': True} + + +async def test_subscribe(pairing): + assert pairing.subscriptions == set() + + await pairing.subscribe([(1, 10)]) + + assert pairing.subscriptions == set(((1, 10), )) + + characteristics = await pairing.get_characteristics([ + (1, 10), + ], include_events=True) + + assert characteristics == { + (1, 10): { + 'ev': True, + 'value': False, + } + } + + +async def test_unsubscribe(pairing): + await pairing.subscribe([(1, 10)]) + + assert pairing.subscriptions == set(((1, 10), )) + + characteristics = await pairing.get_characteristics([ + (1, 10), + ], include_events=True) + + assert characteristics == { + (1, 10): { + 'ev': True, + 'value': False, + } + } + + await pairing.unsubscribe([(1, 10)]) + + assert pairing.subscriptions == set() + + characteristics = await pairing.get_characteristics([ + (1, 10), + ], include_events=True) + + assert characteristics == { + (1, 10): { + 'ev': False, + 'value': False, + } + } + + +async def test_dispatcher_connect(pairing): + assert pairing.listeners == set() + + callback = lambda x: x + cancel = pairing.dispatcher_connect(callback) + assert pairing.listeners == set((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, right = pairings + + 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, 10)]) + + # Trigger an event by writing a change on the other connection + await left.put_characteristics([(1, 10, True)]) + + # Wait for event to be received for up to 5s + await asyncio.wait_for(ev.wait(), 5) + + assert event_value == { + (1, 10): { + 'value': True, + } + } + + +async def test_list_pairings(pairing): + 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): + 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): + 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 == True From e36f18543dd44e0497bbe084cc55e961d887446b Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 8 Dec 2019 07:11:45 +0000 Subject: [PATCH 2/8] Fix running tests on macOS on Travis. --- .travis/install.sh | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.travis/install.sh b/.travis/install.sh index 8dd73950..5915b291 100755 --- a/.travis/install.sh +++ b/.travis/install.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e echo "OS: $TRAVIS_OS_NAME" @@ -16,18 +17,12 @@ if [ "$TRAVIS_OS_NAME" == "linux" ]; then fi if [ "$TRAVIS_OS_NAME" == "osx" ]; then + # Install Python 3.6.5 directly from brew brew update - # see https://github.com/pyenv/pyenv/wiki#suggested-build-environment for Mac OS X - brew install openssl readline sqlite3 xz zlib - openssl version - # pyenv is already installed on a test node - brew outdated pyenv || brew upgrade pyenv - pyenv install --list - pyenv install $PYTHON - pyenv shell $PYTHON - python --version + brew uninstall --ignore-dependencies python + brew install --ignore-dependencies https://raw.githubusercontent.com/Homebrew/homebrew-core/f2a764ef944b1080be64bd88dca9a1d80130c558/Formula/python.rb + python3 --version - pip --version pip3 --version pip3 install -r requirements_osx.txt pip3 install coveralls From 4d6554702a2db70481e5df11b6521db77e5c018c Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 08:50:08 +0000 Subject: [PATCH 3/8] Smoketests for some cli commands --- tests/aio/conftest.py | 11 +++++-- tests/aio/test_main.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/aio/test_main.py diff --git a/tests/aio/conftest.py b/tests/aio/conftest.py index 639fff45..4eb831f3 100644 --- a/tests/aio/conftest.py +++ b/tests/aio/conftest.py @@ -3,6 +3,7 @@ import tempfile import threading import time +from unittest import mock import pytest @@ -72,7 +73,10 @@ async def async_cleanup(): break time.sleep(1) - yield controller + with mock.patch.object(controller, "load_data", lambda x: None): + with mock.patch("homekit.aio.__main__.Controller") as c: + c.return_value = controller + yield controller httpd.shutdown() @@ -141,7 +145,10 @@ async def async_cleanup(): event_loop.run_until_complete(async_cleanup()) request.addfinalizer(cleanup) - yield controller + with mock.patch.object(controller, "load_data", lambda x: None): + with mock.patch("homekit.aio.__main__.Controller") as c: + c.return_value = controller + yield controller httpd.shutdown() diff --git a/tests/aio/test_main.py b/tests/aio/test_main.py new file mode 100644 index 00000000..3c1ce313 --- /dev/null +++ b/tests/aio/test_main.py @@ -0,0 +1,65 @@ +"""Test the AIO CLI variant.""" + +import json +from unittest import mock + +import pytest + +from homekit.aio.__main__ import main + + +# Without this line you would have to mark your async tests with @pytest.mark.asyncio +pytestmark = pytest.mark.asyncio + + +async def test_help(): + with mock.patch("sys.stdout") as stdout: + with pytest.raises(SystemExit): + await main(["-h"]) + printed = stdout.write.call_args[0][0] + + assert printed.startswith("usage: ") + assert "discover_ip" in printed + + +async def test_get_accessories(pairing): + with mock.patch("sys.stdout") as stdout: + await main(["get_accessories", "-f", "pairing.json", "-a", "alias"]) + printed = stdout.write.call_args_list[0][0][0] + assert printed.startswith("1.2: >accessory-information") + + with mock.patch("sys.stdout") as stdout: + await main(["get_accessories", "-f", "pairing.json", "-a", "alias", "-o", "json"]) + printed = stdout.write.call_args_list[0][0][0] + accessories = json.loads(printed) + assert accessories[0]["aid"] == 1 + assert accessories[0]["services"][0]["iid"] == 2 + assert accessories[0]["services"][0]["characteristics"][0]["iid"] == 3 + + +async def test_get_characteristic(pairing): + with mock.patch("sys.stdout") as stdout: + await main(["get_characteristics", "-f", "pairing.json", "-a", "alias", "-c", "1.10"]) + printed = stdout.write.call_args_list[0][0][0] + assert json.loads(printed) == {"1.10": {"value": False}} + + +async def test_put_characteristic(pairing): + with mock.patch("sys.stdout"): + await main(["put_characteristics", "-f", "pairing.json", "-a", "alias", "-c", "1.10", "true"]) + + characteristics = await pairing.get_characteristics([ + (1, 10), + ]) + assert characteristics[(1, 10)] == {'value': True} + + +async def test_list_pairings(pairing): + with mock.patch("sys.stdout") as stdout: + await main(["list_pairings", "-f", "pairing.json", "-a", "alias"]) + printed = "".join(write[0][0] for write in stdout.write.call_args_list) + assert printed == ( + "Pairing Id: decc6fa3-de3e-41c9-adba-ef7409821bfc\n" + "\tPublic Key: 0xd708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8\n" + "\tPermissions: 1 (admin)\n" + ) From cb5fa57664ca66b8db4565e97c35e5a8df7a9cc6 Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 10:56:37 +0000 Subject: [PATCH 4/8] Fix conftest --- tests/aio/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/aio/conftest.py b/tests/aio/conftest.py index 4eb831f3..b74b51ef 100644 --- a/tests/aio/conftest.py +++ b/tests/aio/conftest.py @@ -19,7 +19,7 @@ def port_ready(port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: - s.bind(("127.0.0.1", 5555)) + s.bind(("127.0.0.1", port)) except socket.error as e: if e.errno == errno.EADDRINUSE: return True From 258ac11d99fda1eb2e276d41d193d87946aa34e2 Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 11:18:10 +0000 Subject: [PATCH 5/8] Try and fix macOS Travis --- .travis.yml | 19 +++++++++++++------ .travis/install.sh | 29 ----------------------------- 2 files changed, 13 insertions(+), 35 deletions(-) delete mode 100755 .travis/install.sh diff --git a/.travis.yml b/.travis.yml index b0de4d28..620817ca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,12 +12,19 @@ matrix: python: 3.7 - os: osx language: generic - env: PYTHON=3.5.6 + addons: + homebrew: + packages: python + +install: +- python3 -m venv ./venv +- source venv/bin/activate +- python -m pip install -r requirements.txt -e . +- python -m pip install coveralls -before_install: - - ./.travis/install.sh script: - - flake8 homekit - - coverage run -m pytest tests/ +- python -m flake8 homekit +- python -m coverage run -m pytest tests/ + after_success: - - coveralls +- coveralls diff --git a/.travis/install.sh b/.travis/install.sh deleted file mode 100755 index 5915b291..00000000 --- a/.travis/install.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -e - -echo "OS: $TRAVIS_OS_NAME" - -if [ "$TRAVIS_OS_NAME" == "linux" ]; then - # update openssl to a version that is sufficient for cryptography 2.6 (openssl 1.1 is required since) - wget http://launchpadlibrarian.net/400343104/libssl1.1_1.1.0g-2ubuntu4.3_amd64.deb - sudo dpkg -i libssl1.1_1.1.0g-2ubuntu4.3_amd64.deb - wget http://launchpadlibrarian.net/367327834/openssl_1.1.0g-2ubuntu4_amd64.deb - sudo dpkg -i openssl_1.1.0g-2ubuntu4_amd64.deb - openssl version - sudo apt-get update; - sudo apt-get install -y build-essential python3-dev libdbus-1-dev libdbus-glib-1-dev libgirepository1.0-dev; - pip install -r requirements.txt - pip install coveralls -fi - -if [ "$TRAVIS_OS_NAME" == "osx" ]; then - # Install Python 3.6.5 directly from brew - brew update - brew uninstall --ignore-dependencies python - brew install --ignore-dependencies https://raw.githubusercontent.com/Homebrew/homebrew-core/f2a764ef944b1080be64bd88dca9a1d80130c558/Formula/python.rb - - python3 --version - pip3 --version - pip3 install -r requirements_osx.txt - pip3 install coveralls -fi From 5bed348553f83a1e42a09e859f6a1e5e4a14e308 Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 11:21:45 +0000 Subject: [PATCH 6/8] Don't -e . --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 620817ca..170ecd7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ matrix: install: - python3 -m venv ./venv - source venv/bin/activate -- python -m pip install -r requirements.txt -e . +- python -m pip install -r requirements.txt - python -m pip install coveralls script: From 83782078d8501f9e9b6d39c1a5c3d18a02cbcfd9 Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 11:23:45 +0000 Subject: [PATCH 7/8] Missing deps --- .travis.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 170ecd7e..afbff9c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,13 +15,19 @@ matrix: addons: homebrew: packages: python + install: + - python -m pip install -r requirements_osx.txt -install: +before_install: - python3 -m venv ./venv - source venv/bin/activate -- python -m pip install -r requirements.txt - python -m pip install coveralls +install: +- sudo apt-get update +- sudo apt-get install -y build-essential python3-dev libdbus-1-dev libdbus-glib-1-dev libgirepository1.0-dev +- python -m pip install -r requirements.txt + script: - python -m flake8 homekit - python -m coverage run -m pytest tests/ From 561359033024ef879ce1eb75013e3597f904367e Mon Sep 17 00:00:00 2001 From: John Carr Date: Sun, 12 Jan 2020 13:34:04 +0000 Subject: [PATCH 8/8] Add port ready check to other test integration --- tests/aio/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/aio/conftest.py b/tests/aio/conftest.py index b74b51ef..9fb8fa8e 100644 --- a/tests/aio/conftest.py +++ b/tests/aio/conftest.py @@ -145,6 +145,11 @@ async def async_cleanup(): event_loop.run_until_complete(async_cleanup()) request.addfinalizer(cleanup) + for i in range(10): + if port_ready(51842): + break + time.sleep(1) + with mock.patch.object(controller, "load_data", lambda x: None): with mock.patch("homekit.aio.__main__.Controller") as c: c.return_value = controller