diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..339feb3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv +*.pyc + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..b0de4d28 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: python +matrix: + include: + - os: linux + dist: xenial + python: 3.5 + - os: linux + dist: xenial + python: 3.6 + - os: linux + dist: xenial + python: 3.7 + - os: osx + language: generic + env: PYTHON=3.5.6 + +before_install: + - ./.travis/install.sh +script: + - flake8 homekit + - coverage run -m pytest tests/ +after_success: + - coveralls diff --git a/.travis/install.sh b/.travis/install.sh new file mode 100755 index 00000000..5915b291 --- /dev/null +++ b/.travis/install.sh @@ -0,0 +1,29 @@ +#!/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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md new file mode 100644 index 00000000..ca221fb1 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# aiohomekit + +This library implements the HomeKit protocol for controlling Homekit accessories using asyncio. + +It's primary use is for with Home Assistant. We target the same versions of python as them and try to follow their code standards. + +At the moment we don't offer any API guarantees. API stability and documentation will happen after we are happy with how things are working within Home Assistant. + +## FAQ + +### Can i use this to make a homekit accessory? + +No, this is just the client part. You should use one the of other implementations: + + * [homekit_python](https://github.com/jlusiardi/homekit_python/) + * [HAP-python](https://github.com/ikalchev/HAP-python) + + +## Why don't you use library X instead? + +At the time of writing this is the only python 3.7/3.8 asyncio HAP client. + + +## Thanks + +This library wouldn't have been possible without homekit_python, a synchronous implementation of both the client and server parts of HAP. diff --git a/homekit/__init__.py b/homekit/__init__.py new file mode 100644 index 00000000..99ec3501 --- /dev/null +++ b/homekit/__init__.py @@ -0,0 +1,73 @@ +# +# Copyright 2019 aiohomekit team +# +# 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", + "BluetoothAdapterError", + "AccessoryDisconnectedError", + "AccessoryNotFoundError", + "AlreadyPairedError", + "AuthenticationError", + "BackoffError", + "BusyError", + "CharacteristicPermissionError", + "ConfigLoadingError", + "ConfigSavingError", + "ConfigurationError", + "FormatError", + "HomeKitException", + "HttpException", + "IncorrectPairingIdError", + "InvalidAuthTagError", + "InvalidError", + "InvalidSignatureError", + "MaxPeersError", + "MaxTriesError", + "ProtocolError", + "RequestRejected", + "UnavailableError", + "UnknownError", + "UnpairedError", +] + +from homekit.controller import Controller +from homekit.exceptions import ( + BluetoothAdapterError, + AccessoryDisconnectedError, + AccessoryNotFoundError, + AlreadyPairedError, + AuthenticationError, + BackoffError, + BusyError, + CharacteristicPermissionError, + ConfigLoadingError, + ConfigSavingError, + ConfigurationError, + FormatError, + HomeKitException, + HttpException, + IncorrectPairingIdError, + InvalidAuthTagError, + InvalidError, + InvalidSignatureError, + MaxPeersError, + MaxTriesError, + ProtocolError, + RequestRejected, + UnavailableError, + UnknownError, + UnpairedError, +) diff --git a/homekit/__main__.py b/homekit/__main__.py new file mode 100644 index 00000000..ff43ec65 --- /dev/null +++ b/homekit/__main__.py @@ -0,0 +1,585 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 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/controller/__init__.py b/homekit/controller/__init__.py new file mode 100644 index 00000000..f3cdc22f --- /dev/null +++ b/homekit/controller/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/controller.py b/homekit/controller/controller.py new file mode 100644 index 00000000..7e6a6ecf --- /dev/null +++ b/homekit/controller/controller.py @@ -0,0 +1,240 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/ip/__init__.py b/homekit/controller/ip/__init__.py new file mode 100644 index 00000000..3183d702 --- /dev/null +++ b/homekit/controller/ip/__init__.py @@ -0,0 +1,23 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/ip/connection.py b/homekit/controller/ip/connection.py new file mode 100644 index 00000000..d4076314 --- /dev/null +++ b/homekit/controller/ip/connection.py @@ -0,0 +1,459 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/ip/discovery.py b/homekit/controller/ip/discovery.py new file mode 100644 index 00000000..b7fa14a4 --- /dev/null +++ b/homekit/controller/ip/discovery.py @@ -0,0 +1,133 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/ip/pairing.py b/homekit/controller/ip/pairing.py new file mode 100644 index 00000000..37a3d4b0 --- /dev/null +++ b/homekit/controller/ip/pairing.py @@ -0,0 +1,458 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/controller/ip/zeroconf.py b/homekit/controller/ip/zeroconf.py new file mode 100644 index 00000000..c35ca986 --- /dev/null +++ b/homekit/controller/ip/zeroconf.py @@ -0,0 +1,38 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + +""" +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/controller/pairing.py b/homekit/controller/pairing.py new file mode 100644 index 00000000..1b9836cb --- /dev/null +++ b/homekit/controller/pairing.py @@ -0,0 +1,134 @@ +# +# Copyright 2019 aiohomekit team +# +# 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/homekit/crypto/__init__.py b/homekit/crypto/__init__.py new file mode 100644 index 00000000..88a6cf87 --- /dev/null +++ b/homekit/crypto/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright 2019 aiohomekit team +# +# 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__ = ["chacha20_aead_decrypt", "chacha20_aead_encrypt", "SrpClient", "SrpServer"] + +from homekit.crypto.chacha20poly1305 import chacha20_aead_decrypt, chacha20_aead_encrypt +from homekit.crypto.srp import SrpClient, SrpServer diff --git a/homekit/crypto/chacha20poly1305.py b/homekit/crypto/chacha20poly1305.py new file mode 100644 index 00000000..3a428680 --- /dev/null +++ b/homekit/crypto/chacha20poly1305.py @@ -0,0 +1,338 @@ +# -*- coding: UTF-8 -*- + +# +# Copyright 2019 aiohomekit team +# +# 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. +# + +""" +Implements the ChaCha20 stream cipher and the Poly1350 authenticator. More information can be found on +https://tools.ietf.org/html/rfc7539. See HomeKit spec page 51. +""" +from math import ceil + + +def rotate_left(num: int, num_size: int, shift_bits: int) -> int: + """ + Rotate a number num of num_size bits by shift_bits bits. See + https://en.wikipedia.org/wiki/Bitwise_operation#Rotate_no_carry for more information. + + :param num: the number to rotate + :param num_size: the size of the number in bits + :param shift_bits: the number of bits the number is rotated by + :return: the rotated number + """ + mask = (2 ** shift_bits - 1) << (num_size - shift_bits) + bits = (num & mask) >> (num_size - shift_bits) + num = (num << shift_bits) & (2 ** num_size - 1) + num |= bits + return num + + +def chacha20_quarter_round(s: list, a: int, b: int, c: int, d: int): + """ + Computes a chacha20 quarter round on the given state s as describe in RFC7539 Chapter 2.1. The state gets updated + inplace. + + :param s: the state + :param a: the first index into the state + :param b: the second index into the state + :param c: the third index into the state + :param d: the fourth index into the state + :return: None + """ + assert len(s) == 16, "state must have 16 ints" + s[a] = (s[a] + s[b]) & (2 ** 32 - 1) + s[d] = s[d] ^ s[a] + s[d] = rotate_left(s[d], 32, 16) + + s[c] = (s[c] + s[d]) & (2 ** 32 - 1) + s[b] = s[b] ^ s[c] + s[b] = rotate_left(s[b], 32, 12) + + s[a] = (s[a] + s[b]) & (2 ** 32 - 1) + s[d] = s[d] ^ s[a] + s[d] = rotate_left(s[d], 32, 8) + + s[c] = (s[c] + s[d]) & (2 ** 32 - 1) + s[b] = s[b] ^ s[c] + s[b] = rotate_left(s[b], 32, 7) + + +def convert(num: int) -> int: + return int.from_bytes(num.to_bytes(4, byteorder="little"), byteorder="big") + + +def extract_nth_word(num: bytes, w: int) -> int: + m = len(num) + bs = num[m - 4 * (w + 1) : m - 4 * (w + 0)] + return int.from_bytes(bs, byteorder="big") + + +def chacha20_create_initial_state(key: bytes, nonce: bytes, counter: int) -> list: + """ + Creates the initial chacha20 state for the block function as described in RFC7539 chapter 2.3. + + :param key: the 256 bit key to use as bytes + :param nonce: the 96 bit nonce as bytes + :param counter: the 32bit block counter + :return: the initial state as list of ints + """ + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + assert type(nonce) is bytes, "nonce is no instance of bytes" + assert len(nonce) == 3 * 32 / 8 + state = [ + 0x61707865, + 0x3320646E, + 0x79622D32, + 0x6B206574, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + for i in range(0, 8): + state[4 + i] = convert(extract_nth_word(key, 7 - i)) + state[12] = counter & (2 ** 32 - 1) + state[13] = convert(extract_nth_word(nonce, 2)) + state[14] = convert(extract_nth_word(nonce, 1)) + state[15] = convert(extract_nth_word(nonce, 0)) + return state + + +def chacha20_inner_block(state: list): + """ + The function to perform the inner quarter rounds as described in RFC7539 chapter 2.3.1. The state is updated + inplace. + + :param state: the chacha20 state + :return: None + """ + assert len(state) == 16, "state must have 16 ints" + chacha20_quarter_round(state, 0, 4, 8, 12) + chacha20_quarter_round(state, 1, 5, 9, 13) + chacha20_quarter_round(state, 2, 6, 10, 14) + chacha20_quarter_round(state, 3, 7, 11, 15) + chacha20_quarter_round(state, 0, 5, 10, 15) + chacha20_quarter_round(state, 1, 6, 11, 12) + chacha20_quarter_round(state, 2, 7, 8, 13) + chacha20_quarter_round(state, 3, 4, 9, 14) + + +def chacha20_block(key: bytes, nonce: bytes, counter: int) -> int: + """ + The function to perform the computation for one chacha20 block as described in RFC7539 chapter 2.3.1. + + :param key: the 256 bit key to use as bytes + :param nonce: the 96 bit nonce as bytes + :param counter: the 32bit block counter + :return: the state serialized as int + """ + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + assert type(nonce) is bytes, "nonce is no instance of bytes" + assert len(nonce) == 3 * 32 / 8 + state = chacha20_create_initial_state(key, nonce, counter) + w_state = state.copy() + for i in range(0, 10): + chacha20_inner_block(w_state) + result = 0 + for i in range(0, 16): + state[i] = (state[i] + w_state[i]) & (2 ** 32 - 1) + result <<= 32 + result += convert(state[i]) + return result + + +def chacha20_encrypt(key: bytes, counter: int, nonce: int, plaintext: bytes) -> bytes: + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + assert type(nonce) is bytes, "nonce is no instance of bytes" + assert len(nonce) == 12 + encrypted = bytearray() + for i in range(0, int(len(plaintext) / 64)): + key_stream = chacha20_block(key, nonce, counter + i) + key_stream = key_stream.to_bytes(64, byteorder="big") + block = plaintext[i * 64 : i * 64 + 64] + encrypted += bytes([a ^ b for (a, b) in zip(block, key_stream)]) + + if (len(plaintext) % 64) != 0: + j = int(len(plaintext) / 64) + key_stream = chacha20_block(key, nonce, counter + j) + block = plaintext[(j * 64) : len(plaintext)] + key_stream = key_stream.to_bytes(64, byteorder="big") + key_stream = key_stream[: len(block)] + encrypted += bytes([a ^ b for (a, b) in zip(block, key_stream)]) + return encrypted + + +def clamp(r: int) -> int: + tmp = r.to_bytes(length=16, byteorder="little") + msk = 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF .to_bytes(length=16, byteorder="big") + res = bytearray() + for i in range(0, len(tmp)): + res.append(tmp[i] & msk[i]) + return int.from_bytes(res, byteorder="big") + + +def calc_r(k: int) -> int: + assert type(k) is bytes, "key is no instance of bytes" + assert len(k) == 32 + tmp = k[0:16] + return int.from_bytes(tmp, byteorder="big") + + +def calc_s(k: bytes) -> int: + assert type(k) is bytes, "key is no instance of bytes" + assert len(k) == 32 + tmp = k[16:32] + return int.from_bytes(tmp, byteorder="little") + + +def poly1305_mac(msg: bytes, key: bytes) -> bytes: + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + r = calc_r(key) + r = clamp(r) + s = calc_s(key) + a = 0 + p = (1 << 130) - 5 + for i in range(0, ceil(len(msg) / 16)): + block = bytearray(msg[i * 16 : i * 16 + 16]) + block.append(0x01) + n = int.from_bytes(block, byteorder="little") + a += n + a = r * a + a %= p + a += s + a &= 2 ** (16 * 8) - 1 + return a.to_bytes(length=16, byteorder="little") + + +def poly1305_key_gen(key: bytes, nonce: bytes) -> bytes: + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + assert type(nonce) is bytes, "nonce is no instance of bytes" + assert len(nonce) == 12 + counter = 0 + block = chacha20_block(key, nonce, counter) + mask = (2 ** (32 * 8) - 1) << (32 * 8) + block &= mask + block >>= 32 * 8 + block = block.to_bytes(length=32, byteorder="big") + return block + + +def pad16(x: bytes) -> bytes: + tmp = len(x) % 16 + if tmp == 0: + return bytearray([]) + tmp = 16 - tmp + return bytearray([0 for i in range(0, tmp)]) + + +def chacha20_aead_verify_tag( + aad: bytes, key: bytes, iv: bytes, constant: bytes, ciphertext: bytes +): + digest = ciphertext[-16:] + ciphertext = ciphertext[:-16] + + nonce = constant + iv + otk = poly1305_key_gen(key, nonce) + mac_data = aad + pad16(aad) + assert len(mac_data) % 16 == 0 + mac_data += ciphertext + pad16(ciphertext) + assert len(mac_data) % 16 == 0 + mac_data += len(aad).to_bytes(length=8, byteorder="little") + mac_data += len(ciphertext).to_bytes(length=8, byteorder="little") + tag = poly1305_mac(mac_data, otk) + + return digest == tag + + +def chacha20_aead_encrypt( + aad: bytes, key: bytes, iv: bytes, constant: bytes, plaintext: bytes +): + """ + The encrypt method for chacha20 aead as required by the Apple specification. The 96-bit nonce from RFC7539 is + formed from the constant and the initialisation vector. + + :param aad: arbitrary length additional authenticated data + :param key: 256-bit (32-byte) key of type bytes + :param iv: the initialisation vector + :param constant: constant + :param plaintext: arbitrary length plaintext of type bytes or bytearray + :return: the cipher text and tag + """ + assert type(plaintext) in [ + bytes, + bytearray, + ], "plaintext is no instance of bytes: %s" % str(type(plaintext)) + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + + nonce = constant + iv + otk = poly1305_key_gen(key, nonce) + ciphertext = chacha20_encrypt(key, 1, nonce, plaintext) + assert len(plaintext) == len(ciphertext) + mac_data = aad + pad16(aad) + assert len(mac_data) % 16 == 0 + mac_data += ciphertext + pad16(ciphertext) + assert len(mac_data) % 16 == 0 + mac_data += len(aad).to_bytes(length=8, byteorder="little") + mac_data += len(ciphertext).to_bytes(length=8, byteorder="little") + tag = poly1305_mac(mac_data, otk) + return ciphertext, tag + + +def chacha20_aead_decrypt( + aad: bytes, key: bytes, iv: bytes, constant: bytes, ciphertext: bytes +): + """ + The decrypt method for chacha20 aead as required by the Apple specification. The 96-bit nonce from RFC7539 is + formed from the constant and the initialisation vector. + + :param aad: arbitrary length additional authenticated data + :param key: 256-bit (32-byte) key of type bytes + :param iv: the initialisation vector + :param constant: constant + :param ciphertext: arbitrary length plaintext of type bytes or bytearray + :return: False if the tag could not be verified or the plaintext as bytes + """ + assert type(ciphertext) in [ + bytes, + bytearray, + ], "ciphertext is no instance of bytes: %s" % str(type(ciphertext)) + assert type(key) is bytes, "key is no instance of bytes" + assert len(key) == 32 + + # break up on difference + if not chacha20_aead_verify_tag(aad, key, iv, constant, ciphertext): + return False + + # decrypt and return + ciphertext = ciphertext[:-16] + nonce = constant + iv + plaintext = chacha20_encrypt(key, 1, nonce, ciphertext) + assert len(plaintext) == len(ciphertext) + return plaintext diff --git a/homekit/crypto/srp.py b/homekit/crypto/srp.py new file mode 100644 index 00000000..fd7c25c6 --- /dev/null +++ b/homekit/crypto/srp.py @@ -0,0 +1,282 @@ +# -*- coding: UTF-8 -*- + +# +# Copyright 2019 aiohomekit team +# +# 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. +# + +""" +Implements the Secure Remote Password (SRP) algorithm. More information can be found on +https://tools.ietf.org/html/rfc5054. See HomeKit spec page 36 for adjustments imposed by Apple. +""" +import crypt +import math +import hashlib + + +class Srp: + def __init__(self): + # generator as defined by 3072bit group of RFC 5054 + self.g = int(b"5", 16) + # modulus as defined by 3072bit group of RFC 5054 + self.n = int( + b"""\ +FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08\ +8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B\ +302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9\ +A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE6\ +49286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8\ +FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D\ +670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C\ +180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\ +3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D\ +04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D\ +B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226\ +1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C\ +BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC\ +E0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF""", + 16, + ) + # HomeKit requires SHA-512 (See page 36) + self.h = hashlib.sha512 + self.A = None + self.B = None + self.salt = None + self.username = None + self.password = None + + @staticmethod + def generate_private_key(): + """ + Static function to generate a 16 byte random key. + + :return: the key as an integer + """ + private_key = crypt.mksalt(crypt.METHOD_SHA512)[3:].encode() + return int.from_bytes(private_key, "big") + + def _calculate_k(self) -> int: + # calculate k (see https://tools.ietf.org/html/rfc5054#section-2.5.3) + hash_instance = self.h() + n = Srp.to_byte_array(self.n) + g = bytearray.fromhex((383 * "00" + "05")) # 383 * b'0' + '5'.encode() + hash_instance.update(n) + hash_instance.update(g) + k = int.from_bytes(hash_instance.digest(), "big") + return k + + def _calculate_u(self) -> int: + if self.A is None: + raise RuntimeError("Client's public key is missing") + if self.B is None: + raise RuntimeError("Server's public key is missing") + hash_instance = self.h() + A_b = Srp.to_byte_array(self.A) + B_b = Srp.to_byte_array(self.B) + hash_instance.update(A_b) + hash_instance.update(B_b) + u = int.from_bytes(hash_instance.digest(), "big") + return u + + def get_session_key(self) -> int: + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.get_shared_secret())) + hash_value = int.from_bytes(hash_instance.digest(), "big") + return hash_value + + @staticmethod + def to_byte_array(num: int) -> bytearray: + return bytearray(num.to_bytes(int(math.ceil(num.bit_length() / 8)), "big")) + + def _calculate_x(self) -> int: + i = (self.username + ":" + self.password).encode() + hash_instance = self.h() + hash_instance.update(i) + hash_value = hash_instance.digest() + + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.salt)) + hash_instance.update(hash_value) + + return int.from_bytes(hash_instance.digest(), "big") + + def get_shared_secret(self): + raise NotImplementedError() + + +class SrpClient(Srp): + """ + Implements all functions that are required to simulate an iOS HomeKit controller + """ + + def __init__(self, username: str, password: str): + Srp.__init__(self) + self.username = username + self.password = password + self.salt = None + self.a = self.generate_private_key() + self.A = pow(self.g, self.a, self.n) + self.B = None + + def set_salt(self, salt): + if isinstance(salt, bytearray): + self.salt = int.from_bytes(salt, "big") + else: + self.salt = salt + + def get_public_key(self): + return pow(self.g, self.a, self.n) + + def set_server_public_key(self, B): + if isinstance(B, bytearray): + self.B = int.from_bytes(B, "big") + else: + self.B = B + + def get_shared_secret(self): + if self.B is None: + raise RuntimeError("Server's public key is missing") + u = self._calculate_u() + x = self._calculate_x() + k = self._calculate_k() + tmp1 = self.B - (k * pow(self.g, x, self.n)) + tmp2 = self.a + (u * x) # % self.n + S = pow(tmp1, tmp2, self.n) + return S + + def get_proof(self): + if self.B is None: + raise RuntimeError("Server's public key is missing") + + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.n)) + hN = bytearray(hash_instance.digest()) + + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.g)) + hg = bytearray(hash_instance.digest()) + + for index in range(0, len(hN)): + hN[index] ^= hg[index] + + u = self.username.encode() + hash_instance = self.h() + hash_instance.update(u) + hu = hash_instance.digest() + K = Srp.to_byte_array(self.get_session_key()) + + hash_instance = self.h() + hash_instance.update(hN) + hash_instance.update(hu) + hash_instance.update(Srp.to_byte_array(self.salt)) + hash_instance.update(Srp.to_byte_array(self.A)) + hash_instance.update(Srp.to_byte_array(self.B)) + hash_instance.update(K) + return int.from_bytes(hash_instance.digest(), "big") + + def verify_servers_proof(self, M): + if isinstance(M, bytearray): + tmp = int.from_bytes(M, "big") + else: + tmp = M + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.A)) + hash_instance.update(Srp.to_byte_array(self.get_proof())) + hash_instance.update(Srp.to_byte_array(self.get_session_key())) + return tmp == int.from_bytes(hash_instance.digest(), "big") + + +class SrpServer(Srp): + """ + Implements all functions that are required to simulate an iOS HomeKit accessory + """ + + def __init__(self, username, password): + Srp.__init__(self) + self.username = username + self.salt = SrpServer._create_salt() + self.password = password + self.verifier = self._get_verifier() + self.b = self.generate_private_key() + k = self._calculate_k() + g_b = pow(self.g, self.b, self.n) + self.B = (k * self.verifier + g_b) % self.n + self.A = None + + @staticmethod + def _create_salt() -> int: + # generate random salt + salt = crypt.mksalt(crypt.METHOD_SHA512)[3:] + assert len(salt) == 16 + salt_b = salt.encode() + return int.from_bytes(salt_b, "big") + + def _get_verifier(self) -> int: + hash_value = self._calculate_x() + v = pow(self.g, hash_value, self.n) + return v + + def set_client_public_key(self, A): + self.A = A + + def get_salt(self): + return self.salt + + def get_public_key(self): + k = self._calculate_k() + return (k * self.verifier + pow(self.g, self.b, self.n)) % self.n + + def get_shared_secret(self): + if self.A is None: + raise RuntimeError("Client's public key is missing") + + tmp1 = self.A * pow(self.verifier, self._calculate_u(), self.n) + return pow(tmp1, self.b, self.n) + + def verify_clients_proof(self, m) -> bool: + if self.B is None: + raise RuntimeError("Server's public key is missing") + + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.n)) + hN = bytearray(hash_instance.digest()) + + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.g)) + hg = bytearray(hash_instance.digest()) + + for index in range(0, len(hN)): + hN[index] ^= hg[index] + + u = self.username.encode() + hash_instance = self.h() + hash_instance.update(u) + hu = hash_instance.digest() + K = Srp.to_byte_array(self.get_session_key()) + + hash_instance = self.h() + hash_instance.update(hN) + hash_instance.update(hu) + hash_instance.update(Srp.to_byte_array(self.salt)) + hash_instance.update(Srp.to_byte_array(self.A)) + hash_instance.update(Srp.to_byte_array(self.B)) + hash_instance.update(K) + return m == int.from_bytes(hash_instance.digest(), "big") + + def get_proof(self, m) -> int: + hash_instance = self.h() + hash_instance.update(Srp.to_byte_array(self.A)) + hash_instance.update(Srp.to_byte_array(m)) + hash_instance.update(Srp.to_byte_array(self.get_session_key())) + return int.from_bytes(hash_instance.digest(), "big") diff --git a/homekit/exceptions.py b/homekit/exceptions.py new file mode 100644 index 00000000..5eb191fc --- /dev/null +++ b/homekit/exceptions.py @@ -0,0 +1,280 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class HomeKitException(Exception): + """Generic HomeKit exception. + Attributes: + stage: the stage that the exception occurred at + """ + + def __init__(self, stage): + self.stage = stage + + pass + + +class BluetoothAdapterError(HomeKitException): + pass + + +class MalformedPinError(HomeKitException): + """ + Class to represent a malformed pin (not following the form DDD-DD-DDD) + """ + + pass + + +class ProtocolError(HomeKitException): + """ + Class to represent an abstraction layer for all errors that are defined in the Error Codes table 4-5 page 60 of the + specification. + """ + + pass + + +class UnknownError(ProtocolError): + """ + Raised upon receipt of an unknown error (transmission of kTLVError_Unknown). The spec says that this can happen + during "Add Pairing" (chapter 4.11 page 51) and "Remove Pairing" (chapter 4.12 page 53). + """ + + pass + + +class AuthenticationError(ProtocolError): + """ + Raised upon receipt of an authentication error. This can happen on: + * multiple occasions through out setup pairing (M4 / page 42, M5 page 45): if the pairing could not be established + * during pair verify (M4 / page 50): if the session key could not be generated + * during add pair (M2 / page 52): if the controller is not admin + * during remove pairing (M2 / page 54): if the controller is not admin + * during list pairing (M2 / page 56): if the controller is not admin + """ + + pass + + +class BackoffError(ProtocolError): + """ + Raised upon receipt of a back off error. It seems unclear when this is raised, must be related to + kTLVType_RetryDelay which is defined on page 61 of the spec. + """ + + pass + + +class MaxPeersError(ProtocolError): + """ + Raised upon receipt of a max peers error. This can happen: + * during executing a "pair setup" command + * during an "add pairing" command + """ + + pass + + +class MaxTriesError(ProtocolError): + """ + Raised upon receipt of a max tries error during a pair setup procedure. This happens if more than 100 unsuccessful + authentication attempts were performed. + """ + + pass + + +class UnavailableError(ProtocolError): + """Raised upon receipt of an unavailable error""" + + pass + + +class BusyError(ProtocolError): + """ + Raised upon receipt of a busy error during a pair setup procedure. This happens only if a parallel pairing process + is ongoing. + """ + + pass + + +class InvalidError(ProtocolError): + """ + Raised upon receipt of an error not defined in the HomeKit spec. This should basically never be raised since it is + the default error in the protocol's error handler. + """ + + pass + + +class HttpException(Exception): + """ + Used within the HTTP Parser. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class InvalidAuthTagError(ProtocolError): + """ + Raised upon receipt of an invalid auth tag in Pair Verify Step 3.3 (Page 49). + """ + + pass + + +class IncorrectPairingIdError(ProtocolError): + """ + Raised in Pair Verify Step 3.5 (Page 49) if the accessory responds with an unexpected pairing id. + """ + + pass + + +class InvalidSignatureError(ProtocolError): + """ + Raised upon receipt of an invalid signature either from an accessory or from the controller. + """ + + pass + + +class ConfigurationError(HomeKitException): + """ + Used if any configuration in the HomeKit AccessoryServer's context was wrong. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class FormatError(HomeKitException): + """ + Used if any format conversion fails or is impossible. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class CharacteristicPermissionError(HomeKitException): + """ + Used if the characteristic's permissions do not allow the action. This includes reads on write only characteristics + and writes on read only characteristics. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class AccessoryNotFoundError(HomeKitException): + """ + Used if a HomeKit Accessory's IP and port could not be received via Bonjour / Zeroconf. This might be a temporary + issue due to the way Bonjour / Zeroconf works. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class EncryptionError(HomeKitException): + """ + Used if during a transmission some errors occurred. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class AccessoryDisconnectedError(HomeKitException): + """ + Used if a HomeKit disconnects part way through an operation or series of operations. + + It may be possible to reconnect and retry the request. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class ConfigLoadingError(HomeKitException): + """ + Used on problems loading some config. This includes but may not be limited to: + * problems with file permissions (file not readable) + * the file could not be found + * the file does not contain parseable JSON + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class ConfigSavingError(HomeKitException): + """ + Used on problems saving some config. This includes but may not be limited to: + * problems with file permissions (file not writable) + * the file could not be found (occurs if the path does not exist) + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class UnpairedError(HomeKitException): + """ + This should be raised if a paired accessory is expected but the accessory is still unpaired. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class AlreadyPairedError(HomeKitException): + """ + This should be raised if an unpaired accessory is expected but the accessory is already paired. + """ + + def __init__(self, message): + Exception.__init__(self, message) + + +class RequestRejected(HomeKitException): + """ + Raised when a request fails with a HAP error code + """ + + def __init__(self, message, error_code): + self.error_code = error_code + self.message = message + Exception.__init__(message) + + +class TransportNotSupportedError(HomeKitException): + def __init__(self, transport): + Exception.__init__( + self, + "Transport {t} not supported. See setup.py for required dependencies.".format( + t=transport + ), + ) + + +class DisconnectedControllerError(HomeKitException): + def __init__(self): + Exception.__init__(self, "Controller has passed away") diff --git a/homekit/http/__init__.py b/homekit/http/__init__.py new file mode 100644 index 00000000..78d4d471 --- /dev/null +++ b/homekit/http/__init__.py @@ -0,0 +1,62 @@ +# +# Copyright 2019 aiohomekit team +# +# 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__ = ["HttpContentTypes", "HttpStatusCodes"] + + +class _HttpContentTypes: + JSON = "application/hap+json" + TLV = "application/pairing+tlv8" + + +class _HttpStatusCodes: + """ + See Table 4-2 Chapter 4.15 Page 59 + """ + + OK = 200 + NO_CONTENT = 204 + MULTI_STATUS = 207 + BAD_REQUEST = 400 + FORBIDDEN = 403 + NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 + TOO_MANY_REQUESTS = 429 + CONNECTION_AUTHORIZATION_REQUIRED = 470 + INTERNAL_SERVER_ERROR = 500 + + def __init__(self): + self._codes = { + _HttpStatusCodes.OK: "OK", + _HttpStatusCodes.NO_CONTENT: "No Content", + _HttpStatusCodes.MULTI_STATUS: "Multi-Status", + _HttpStatusCodes.BAD_REQUEST: "Bad Request", + _HttpStatusCodes.METHOD_NOT_ALLOWED: "Method Not Allowed", + _HttpStatusCodes.TOO_MANY_REQUESTS: "Too Many Requests", + _HttpStatusCodes.CONNECTION_AUTHORIZATION_REQUIRED: "Connection Authorization Required", + _HttpStatusCodes.INTERNAL_SERVER_ERROR: "Internal Server Error", + } + self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} + + def __getitem__(self, item): + if item in self._codes: + return self._codes[item] + + raise KeyError("Item {item} not found".format(item=item)) + + +HttpStatusCodes = _HttpStatusCodes() +HttpContentTypes = _HttpContentTypes diff --git a/homekit/http/content_types.py b/homekit/http/content_types.py new file mode 100644 index 00000000..b2e9fcab --- /dev/null +++ b/homekit/http/content_types.py @@ -0,0 +1,27 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _HttpContentTypes: + """ + Collection of HTTP content types as used in HTTP headers + """ + + JSON = "application/hap+json" + TLV = "application/pairing+tlv8" + + +HttpContentTypes = _HttpContentTypes diff --git a/homekit/http/response.py b/homekit/http/response.py new file mode 100644 index 00000000..a92e3835 --- /dev/null +++ b/homekit/http/response.py @@ -0,0 +1,135 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 homekit.exceptions import HttpException + + +class HttpResponse(object): + STATE_PRE_STATUS = 0 + STATE_HEADERS = 1 + STATE_BODY = 2 + STATE_DONE = 3 + + def __init__(self): + self._state = HttpResponse.STATE_PRE_STATUS + self._raw_response = bytearray() + self._is_ready = False + self._is_chunked = False + self._had_empty_chunk = False + self._content_length = -1 + self.version = None + self.code = None + self.reason = None + self.headers = [] + self.body = bytearray() + + def parse(self, part): + self._raw_response += part + pos = self._raw_response.find(b"\r\n") + while pos != -1: + line = self._raw_response[:pos] + self._raw_response = self._raw_response[pos + 2 :] + if self._state == HttpResponse.STATE_PRE_STATUS: + # parse status line + line = line.split(b" ", 2) + if len(line) != 3: + raise HttpException("Malformed status line.") + self.version = line[0].decode() + self.code = int(line[1]) + self.reason = line[2].decode() + self._state = HttpResponse.STATE_HEADERS + + elif self._state == HttpResponse.STATE_HEADERS and line == b"": + # this is the empty line after the headers + self._state = HttpResponse.STATE_BODY + + elif self._state == HttpResponse.STATE_HEADERS: + # parse a header line + line = line.split(b":", 1) + name = line[0].decode() + value = line[1].decode().strip() + if name == "Transfer-Encoding": + if value == "chunked": + self._is_chunked = True + elif name == "Content-Length": + self._content_length = int(value) + self.headers.append((name, value)) + + elif self._state == HttpResponse.STATE_BODY: + if self._is_chunked: + length = int(line, 16) + if length + 2 > len(self._raw_response): + self._raw_response = line + b"\r\n" + self._raw_response + # the remaining bytes in raw response are not sufficient. bail out and wait for an other call. + break + if length == 0: + self._had_empty_chunk = True + self._state = HttpResponse.STATE_DONE + self._raw_response = self._raw_response[length + 2 :] + else: + line = self._raw_response[:length] + self.body += line + self._raw_response = self._raw_response[length + 2 :] + if self._content_length > -1: + self.body += self._raw_response + self._raw_response = bytearray() + else: + raise HttpException("Unknown parser state") + + pos = self._raw_response.find(b"\r\n") + + if self._state == HttpResponse.STATE_BODY and self._content_length > 0: + remaining = self._content_length - len(self.body) + self.body += self._raw_response[:remaining] + self._raw_response = self._raw_response[remaining:] + + if self.is_read_completely(): + # Whatever is left in the buffer is part of the next request + return self._raw_response + + return bytearray() + + def read(self): + """ + Returns the body of the response. + + :return: The read body or None if no body content was read yet + """ + return self.body + + def is_read_completely(self): + if self._is_chunked: + return self._had_empty_chunk + if self.code == 204: + return True + if self._content_length != -1: + return len(self.body) == self._content_length + if ( + self._state == HttpResponse.STATE_PRE_STATUS + or self._state == HttpResponse.STATE_HEADERS + ): + return False + raise HttpException("Could not determine if HTTP data was read completely") + + def get_http_name(self): + """ + Returns the HTTP name (e.g. HTTP or EVENT). + + :return: The name or None if the status line was not yet read + """ + if self.version is not None: + return self.version.split("/")[0] + return None diff --git a/homekit/model/__init__.py b/homekit/model/__init__.py new file mode 100644 index 00000000..1de83c0e --- /dev/null +++ b/homekit/model/__init__.py @@ -0,0 +1,96 @@ +# +# Copyright 2019 aiohomekit team +# +# 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__ = [ + "AccessoryInformationService", + "BHSLightBulbService", + "FanService", + "LightBulbService", + "ThermostatService", + "Categories", + "CharacteristicPermissions", + "CharacteristicFormats", + "FeatureFlags", + "Accessory", +] + +import json +from homekit.model.mixin import ToDictMixin, get_id +from homekit.model.services import ( + AccessoryInformationService, + LightBulbService, + FanService, + BHSLightBulbService, + ThermostatService, +) +from homekit.model.categories import Categories +from homekit.model.characteristics import ( + CharacteristicPermissions, + CharacteristicFormats, +) +from homekit.model.feature_flags import FeatureFlags +from homekit.model.characteristics import IdentifyCharacteristic + + +class Accessory(ToDictMixin): + def __init__(self, name, manufacturer, model, serial_number, firmware_revision): + self.aid = get_id() + self.services = [ + AccessoryInformationService( + name, manufacturer, model, serial_number, firmware_revision + ) + ] + + def add_service(self, service): + self.services.append(service) + + def set_identify_callback(self, func): + """ + Set the callback function for this accessory. This function will be called on paired calls to identify. + + :param func: a function without any parameters and without return type. + """ + + def tmp(x): + func() + + for service in self.services: + if isinstance(service, AccessoryInformationService): + for characteristic in service.characteristics: + if isinstance(characteristic, IdentifyCharacteristic): + characteristic.set_set_value_callback(tmp) + + def to_accessory_and_service_list(self): + services_list = [] + for s in self.services: + services_list.append(s.to_accessory_and_service_list()) + d = {"aid": self.aid, "services": services_list} + return d + + +class Accessories(ToDictMixin): + def __init__(self): + self.accessories = [] + + def add_accessory(self, accessory: Accessory): + self.accessories.append(accessory) + + def to_accessory_and_service_list(self): + accessories_list = [] + for a in self.accessories: + accessories_list.append(a.to_accessory_and_service_list()) + d = {"accessories": accessories_list} + return json.dumps(d) diff --git a/homekit/model/categories.py b/homekit/model/categories.py new file mode 100644 index 00000000..681500fd --- /dev/null +++ b/homekit/model/categories.py @@ -0,0 +1,108 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _Categories(object): + """ + This data is taken from Table 12-3 Accessory Categories on page 254. Values above 19 are reserved. + Additional categories ( 20-23 pulled from + https://github.com/abedinpour/HAS/blob/master/src/categories.ts ) + """ + + OTHER = 1 + BRIDGE = 2 + FAN = 3 + GARAGE = 4 + LIGHTBULB = 5 + DOOR_LOCK = 6 + OUTLET = 7 + SWITCH = 8 + THERMOSTAT = 9 + SENSOR = 10 + SECURITY_SYSTEM = 11 + DOOR = 12 + WINDOW = 13 + WINDOW_COVERING = 14 + PROGRAMMABLE_SWITCH = 15 + RANGE_EXTENDER = 16 + IP_CAMERA = 17 + VIDEO_DOOR_BELL = 18 + AIR_PURIFIER = 19 + # new categories from 2nd release of the apple spec + HEATER = 20 + AIRCONDITIONER = 21 + HUMIDIFIER = 22 + DEHUMIDIFER = 23 + SPRINKLER = 28 + FAUCET = 29 + SHOWER_SYSTEM = 30 + TV = 31 + REMOTE = 32 + + def __init__(self): + self._categories = { + _Categories.OTHER: "Other", + _Categories.BRIDGE: "Bridge", + _Categories.FAN: "Fan", + _Categories.GARAGE: "Garage", + _Categories.LIGHTBULB: "Lightbulb", + _Categories.DOOR_LOCK: "Door Lock", + _Categories.OUTLET: "Outlet", + _Categories.SWITCH: "Switch", + _Categories.THERMOSTAT: "Thermostat", + _Categories.SENSOR: "Sensor", + _Categories.SECURITY_SYSTEM: "Security System", + _Categories.DOOR: "Door", + _Categories.WINDOW: "Window", + _Categories.WINDOW_COVERING: "Window Covering", + _Categories.PROGRAMMABLE_SWITCH: "Programmable Switch", + _Categories.RANGE_EXTENDER: "Range Extender", + _Categories.IP_CAMERA: "IP Camera", + _Categories.VIDEO_DOOR_BELL: "Video Door Bell", + _Categories.AIR_PURIFIER: "Air Purifier", + _Categories.HEATER: "Heater", + _Categories.AIRCONDITIONER: "Air Conditioner", + _Categories.HUMIDIFIER: "Humidifier", + _Categories.DEHUMIDIFER: "Dehumidifier", + _Categories.SPRINKLER: "Sprinkler", + _Categories.FAUCET: "Faucet", + _Categories.SHOWER_SYSTEM: "Shower System", + _Categories.TV: "TV", + _Categories.REMOTE: "Remote", + } + + self._categories_rev = {self._categories[k]: k for k in self._categories.keys()} + + def __contains__(self, item): + if item in self._categories: + return True + + if item in self._categories_rev: + return True + + return False + + def __getitem__(self, item): + if item in self._categories: + return self._categories[item] + + if item in self._categories_rev: + return self._categories_rev[item] + + raise KeyError("Item {item} not found".format(item=item)) + + +Categories = _Categories() diff --git a/homekit/model/characteristics/__init__.py b/homekit/model/characteristics/__init__.py new file mode 100644 index 00000000..f154186d --- /dev/null +++ b/homekit/model/characteristics/__init__.py @@ -0,0 +1,116 @@ +# +# Copyright 2019 aiohomekit team +# +# 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__ = [ + "CharacteristicsTypes", + "CharacteristicPermissions", + "AbstractCharacteristic", + "BatteryLevelCharacteristic", + "BatteryLevelCharacteristicMixin", + "BrightnessCharacteristic", + "BrightnessCharacteristicMixin", + "CharacteristicFormats", + "CharacteristicUnits", + "CurrentHeatingCoolingStateCharacteristic", + "CurrentHeatingCoolingStateCharacteristicMixin", + "CurrentTemperatureCharacteristic", + "CurrentTemperatureCharacteristicMixin", + "FirmwareRevisionCharacteristic", + "HardwareRevisionCharacteristic", + "HueCharacteristic", + "HueCharacteristicMixin", + "IdentifyCharacteristic", + "ManufacturerCharacteristic", + "ModelCharacteristic", + "NameCharacteristic", + "OnCharacteristic", + "OnCharacteristicMixin", + "OutletInUseCharacteristic", + "OutletInUseCharacteristicMixin", + "SaturationCharacteristic", + "SaturationCharacteristicMixin", + "SerialNumberCharacteristic", + "TargetHeatingCoolingStateCharacteristic", + "TargetHeatingCoolingStateCharacteristicMixin", + "TargetTemperatureCharacteristic", + "TargetTemperatureCharacteristicMixin", + "TemperatureDisplayUnitCharacteristic", + "TemperatureDisplayUnitsMixin", + "VolumeCharacteristic", + "VolumeCharacteristicMixin", +] + +from homekit.model.characteristics.characteristic_permissions import ( + CharacteristicPermissions, +) +from homekit.model.characteristics.characteristic_types import CharacteristicsTypes +from homekit.model.characteristics.characteristic_units import CharacteristicUnits +from homekit.model.characteristics.characteristic_formats import CharacteristicFormats + +from homekit.model.characteristics.abstract_characteristic import AbstractCharacteristic +from homekit.model.characteristics.battery_level import ( + BatteryLevelCharacteristic, + BatteryLevelCharacteristicMixin, +) +from homekit.model.characteristics.brightness import ( + BrightnessCharacteristicMixin, + BrightnessCharacteristic, +) +from homekit.model.characteristics.current_heating_cooling_state import ( + CurrentHeatingCoolingStateCharacteristicMixin, + CurrentHeatingCoolingStateCharacteristic, +) +from homekit.model.characteristics.current_temperature import ( + CurrentTemperatureCharacteristicMixin, + CurrentTemperatureCharacteristic, +) +from homekit.model.characteristics.firmware_revision import ( + FirmwareRevisionCharacteristic, +) +from homekit.model.characteristics.hardware_revision import ( + HardwareRevisionCharacteristic, +) +from homekit.model.characteristics.hue import HueCharacteristicMixin, HueCharacteristic +from homekit.model.characteristics.identify import IdentifyCharacteristic +from homekit.model.characteristics.manufacturer import ManufacturerCharacteristic +from homekit.model.characteristics.model import ModelCharacteristic +from homekit.model.characteristics.name import NameCharacteristic +from homekit.model.characteristics.on import OnCharacteristicMixin, OnCharacteristic +from homekit.model.characteristics.outlet_in_use import ( + OutletInUseCharacteristic, + OutletInUseCharacteristicMixin, +) +from homekit.model.characteristics.saturation import ( + SaturationCharacteristicMixin, + SaturationCharacteristic, +) +from homekit.model.characteristics.serialnumber import SerialNumberCharacteristic +from homekit.model.characteristics.target_heating_cooling_state import ( + TargetHeatingCoolingStateCharacteristic, + TargetHeatingCoolingStateCharacteristicMixin, +) +from homekit.model.characteristics.target_temperature import ( + TargetTemperatureCharacteristicMixin, + TargetTemperatureCharacteristic, +) +from homekit.model.characteristics.temperature_display_unit import ( + TemperatureDisplayUnitsMixin, + TemperatureDisplayUnitCharacteristic, +) +from homekit.model.characteristics.volume import ( + VolumeCharacteristic, + VolumeCharacteristicMixin, +) diff --git a/homekit/model/characteristics/abstract_characteristic.py b/homekit/model/characteristics/abstract_characteristic.py new file mode 100644 index 00000000..41e4df31 --- /dev/null +++ b/homekit/model/characteristics/abstract_characteristic.py @@ -0,0 +1,251 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 distutils.util import strtobool +import base64 +import binascii +from decimal import Decimal +import struct + +from homekit.model.mixin import ToDictMixin +from homekit.model.characteristics import ( + CharacteristicsTypes, + CharacteristicFormats, + CharacteristicPermissions, +) +from homekit.protocol.statuscodes import HapStatusCodes +from homekit.exceptions import CharacteristicPermissionError, FormatError + + +class AbstractCharacteristic(ToDictMixin): + def __init__(self, iid: int, characteristic_type: str, characteristic_format: str): + if type(self) is AbstractCharacteristic: + raise Exception( + "AbstractCharacteristic is an abstract class and cannot be instantiated directly" + ) + self.type = CharacteristicsTypes.get_uuid( + characteristic_type + ) # page 65, see ServicesTypes + self.iid = iid # page 65, unique instance id + self.perms = [ + CharacteristicPermissions.paired_read + ] # page 65, array of values from CharacteristicPermissions + self.format = characteristic_format # page 66, one of CharacteristicsTypes + self.value = None # page 65, required but depends on format + + self.ev = None # boolean, not required, page 65 + self.description = None # string, not required, page 65 + self.unit = None # string, not required,page 66, valid values are in CharacteristicUnits + self.minValue = ( + None # number, not required, page 66, used if format is int* or float + ) + self.maxValue = ( + None # number, not required, page 66, used if format is int* or float + ) + self.minStep = ( + None # number, not required, page 66, used if format is int* or float + ) + self.maxLen = 64 # number, not required, page 66, used if format is string + self.maxDataLen = ( + 2097152 # number, not required, page 66, used if format is data + ) + self.valid_values = None # array, not required, see page 67, all numeric entries are allowed values + self.valid_values_range = None # 2 element array, not required, see page 67 + + self._set_value_callback = None + self._get_value_callback = None + + def set_set_value_callback(self, callback): + self._set_value_callback = callback + + def set_get_value_callback(self, callback): + self._get_value_callback = callback + + def set_events(self, new_val): + self.ev = new_val + + def set_value(self, new_val): + """ + This function sets the value of this characteristic. Permissions are checked first + + :param new_val: + :raises CharacteristicPermissionError: if the characteristic cannot be written + """ + if CharacteristicPermissions.paired_write not in self.perms: + raise CharacteristicPermissionError(HapStatusCodes.CANT_WRITE_READ_ONLY) + try: + # convert input to python int if it is any kind of int + if self.format in [ + CharacteristicFormats.uint64, + CharacteristicFormats.uint32, + CharacteristicFormats.uint16, + CharacteristicFormats.uint8, + CharacteristicFormats.int, + ]: + new_val = int(new_val) + # convert input to python float + if self.format == CharacteristicFormats.float: + new_val = float(new_val) + # convert to python bool + if self.format == CharacteristicFormats.bool: + new_val = strtobool(str(new_val)) + except ValueError: + raise FormatError(HapStatusCodes.INVALID_VALUE) + + if self.format in [ + CharacteristicFormats.uint64, + CharacteristicFormats.uint32, + CharacteristicFormats.uint16, + CharacteristicFormats.uint8, + CharacteristicFormats.int, + CharacteristicFormats.float, + ]: + if self.minValue is not None and new_val < self.minValue: + raise FormatError(HapStatusCodes.INVALID_VALUE) + if self.maxValue is not None and self.maxValue < new_val: + raise FormatError(HapStatusCodes.INVALID_VALUE) + if self.minStep is not None: + tmp = new_val + + # if minValue is set, the steps count from this on + if self.minValue is not None: + tmp -= self.minValue + + # use Decimal to calculate the module because it has not the precision problem as float... + if Decimal(str(tmp)) % Decimal(str(self.minStep)) != 0: + raise FormatError(HapStatusCodes.INVALID_VALUE) + if self.valid_values is not None and new_val not in self.valid_values: + raise FormatError(HapStatusCodes.INVALID_VALUE) + if self.valid_values_range is not None and not ( + self.valid_values_range[0] <= new_val <= self.valid_values_range[1] + ): + raise FormatError(HapStatusCodes.INVALID_VALUE) + + if self.format == CharacteristicFormats.data: + try: + byte_data = base64.decodebytes(new_val.encode()) + except binascii.Error: + raise FormatError(HapStatusCodes.INVALID_VALUE) + except Exception: + raise FormatError(HapStatusCodes.OUT_OF_RESOURCES) + if self.maxDataLen < len(byte_data): + raise FormatError(HapStatusCodes.INVALID_VALUE) + + if self.format == CharacteristicFormats.string: + if len(new_val) > self.maxLen: + raise FormatError(HapStatusCodes.INVALID_VALUE) + + self.value = new_val + if self._set_value_callback: + self._set_value_callback(new_val) + + def set_value_from_ble(self, value): + if self.format == CharacteristicFormats.bool: + value = struct.unpack("?", value)[0] + elif self.format == CharacteristicFormats.uint8: + value = struct.unpack("B", value)[0] + elif self.format == CharacteristicFormats.uint16: + value = struct.unpack("H", value)[0] + elif self.format == CharacteristicFormats.uint32: + value = struct.unpack("I", value)[0] + elif self.format == CharacteristicFormats.uint64: + value = struct.unpack("Q", value)[0] + elif self.format == CharacteristicFormats.int: + value = struct.unpack("i", value)[0] + elif self.format == CharacteristicFormats.float: + value = struct.unpack("f", value)[0] + elif self.format == CharacteristicFormats.string: + value = value.decode("UTF-8") + else: + value = value.hex() + + self.set_value(value) + + def get_value(self): + """ + This method returns the value of this characteristic. Permissions are checked first, then either the callback + for getting the values is executed (execution time may vary) or the value is directly returned if not callback + is given. + + :raises CharacteristicPermissionError: if the characteristic cannot be read + :return: the value of the characteristic + """ + if CharacteristicPermissions.paired_read not in self.perms: + raise CharacteristicPermissionError(HapStatusCodes.CANT_READ_WRITE_ONLY) + if self._get_value_callback: + return self._get_value_callback() + return self.value + + def get_value_for_ble(self): + value = self.get_value() + + if self.format == CharacteristicFormats.bool: + try: + val = strtobool(str(value)) + except ValueError: + raise FormatError( + '"{v}" is no valid "{t}"!'.format(v=value, t=self.format) + ) + + value = struct.pack("?", val) + elif self.format == CharacteristicFormats.int: + value = struct.pack("i", int(value)) + elif self.format == CharacteristicFormats.float: + value = struct.pack("f", float(value)) + elif self.format == CharacteristicFormats.string: + value = value.encode() + + return value + + def get_meta(self): + """ + This method returns a dict of meta information for this characteristic. This includes at least the format of + the characteristic but may contain any other specific attribute. + + :return: a dict + """ + tmp = {"format": self.format} + # TODO implement handling of already defined maxLen (upto 256!) + if self.format == CharacteristicFormats.string: + tmp["maxLen"] = 64 + # TODO implement handling of other fields! eg maxDataLen + return tmp + + def to_accessory_and_service_list(self): + d = { + "type": self.type, + "iid": self.iid, + "perms": self.perms, + "format": self.format, + } + if CharacteristicPermissions.paired_read in self.perms: + d["value"] = self.value + if self.ev: + d["ev"] = self.ev + if self.description: + d["description"] = self.description + if self.unit: + d["unit"] = self.unit + if self.minValue: + d["minValue"] = self.minValue + if self.maxValue: + d["maxValue"] = self.maxValue + if self.minStep: + d["minStep"] = self.minStep + if self.maxLen and self.format in [CharacteristicFormats.string]: + d["maxLen"] = self.maxLen + + return d diff --git a/homekit/model/characteristics/characteristic_formats.py b/homekit/model/characteristics/characteristic_formats.py new file mode 100644 index 00000000..8fa7ebff --- /dev/null +++ b/homekit/model/characteristics/characteristic_formats.py @@ -0,0 +1,66 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class CharacteristicFormats(object): + """ + Values for characteristic's format taken from table 5-5 page 67 + """ + + bool = "bool" + uint8 = "uint8" + uint16 = "uint16" + uint32 = "uint32" + uint64 = "uint64" + int = "int" + float = "float" + string = "string" + tlv8 = "tlv8" + data = "data" + + +class _BleCharacteristicFormats(object): + """ + Mapping taken from Table 6-36 page 129 and + https://developer.nordicsemi.com/nRF5_SDK/nRF51_SDK_v4.x.x/doc/html/group___b_l_e___g_a_t_t___c_p_f___f_o_r_m_a_t_s.html + """ + + def __init__(self): + self._formats = { + 0x01: "bool", + 0x04: "uint8", + 0x06: "uint16", + 0x08: "uint32", + 0x0A: "uint64", + 0x10: "int", + 0x14: "float", + 0x19: "string", + 0x1B: "data", + } + + self._formats_rev = {v: k for (k, v) in self._formats.items()} + + def get(self, key, default): + return self._formats.get(key, default) + + def get_reverse(self, key, default): + return self._formats_rev.get(key, default) + + +# +# Have a singleton to avoid overhead +# +BleCharacteristicFormats = _BleCharacteristicFormats() diff --git a/homekit/model/characteristics/characteristic_permissions.py b/homekit/model/characteristics/characteristic_permissions.py new file mode 100644 index 00000000..c33b0c15 --- /dev/null +++ b/homekit/model/characteristics/characteristic_permissions.py @@ -0,0 +1,28 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class CharacteristicPermissions(object): + """ + See table 5-4 page 67 + """ + + paired_read = "pr" + paired_write = "pw" + events = "ev" + addition_authorization = "aa" + timed_write = "tw" + hidden = "hd" diff --git a/homekit/model/characteristics/characteristic_types.py b/homekit/model/characteristics/characteristic_types.py new file mode 100644 index 00000000..dcdf9816 --- /dev/null +++ b/homekit/model/characteristics/characteristic_types.py @@ -0,0 +1,403 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 uuid + + +class _CharacteristicsTypes(object): + """ + Translate the characteristic's UUIDs into the type description (as defined by Apple). + E.g: + "6D" becomes "0000006D-0000-1000-8000-0026BB765291" and translates to + "public.hap.characteristic.position.current" or "position.current" + Data is taken from chapter 8 of the specification (page 144ff) + """ + + ACCESSORY_PROPERTIES = "A6" + ACTIVE = "B0" + ACTIVE_IDENTIFIER = "E7" + ADMINISTRATOR_ONLY_ACCESS = "1" + AIR_PARTICULATE_DENSITY = "64" + AIR_PARTICULATE_SIZE = "65" + AIR_PURIFIER_STATE_CURRENT = "A9" + AIR_PURIFIER_STATE_TARGET = "A8" + AIR_QUALITY = "95" + AUDIO_FEEDBACK = "5" + BATTERY_LEVEL = "68" + BRIGHTNESS = "8" + BUTTON_EVENT = "126" + CARBON_DIOXIDE_DETECTED = "92" + CARBON_DIOXIDE_LEVEL = "93" + CARBON_DIOXIDE_PEAK_LEVEL = "94" + CARBON_MONOXIDE_DETECTED = "69" + CARBON_MONOXIDE_LEVEL = "90" + CARBON_MONOXIDE_PEAK_LEVEL = "91" + CHARGING_STATE = "8F" + COLOR_TEMPERATURE = "CE" + CONTACT_STATE = "6A" + CURRENT_HEATER_COOLER_STATE = "B1" + CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE = "B3" + DENSITY_NO2 = "C4" + DENSITY_OZONE = "C3" + DENSITY_PM10 = "C7" + DENSITY_PM25 = "C6" + DENSITY_SO2 = "C5" + DENSITY_VOC = "C8" + DOOR_STATE_CURRENT = "E" + DOOR_STATE_TARGET = "32" + FAN_STATE_CURRENT = "AF" + FAN_STATE_TARGET = "BF" + FILTER_CHANGE_INDICATION = "AC" + FILTER_LIFE_LEVEL = "AB" + FILTER_RESET_INDICATION = "AD" + FIRMWARE_REVISION = "52" + HARDWARE_REVISION = "53" + HEATING_COOLING_CURRENT = "F" + HEATING_COOLING_TARGET = "33" + HORIZONTAL_TILT_CURRENT = "6C" + HORIZONTAL_TILT_TARGET = "7B" + HUE = "13" + IDENTIFY = "14" + IMAGE_MIRROR = "11F" + IMAGE_ROTATION = "11E" + INPUT_EVENT = "73" + IN_USE = "D2" + IS_CONFIGURED = "D6" + LEAK_DETECTED = "70" + LIGHT_LEVEL_CURRENT = "6B" + LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT = "1A" + LOCK_MANAGEMENT_CONTROL_POINT = "19" + LOCK_MECHANISM_CURRENT_STATE = "1D" + LOCK_MECHANISM_LAST_KNOWN_ACTION = "1C" + LOCK_MECHANISM_TARGET_STATE = "1E" + LOCK_PHYSICAL_CONTROLS = "A7" + LOGS = "1F" + MANUFACTURER = "20" + MODEL = "21" + MOTION_DETECTED = "22" + MUTE = "11A" + NAME = "23" + NIGHT_VISION = "11B" + OBSTRUCTION_DETECTED = "24" + OCCUPANCY_DETECTED = "71" + ON = "25" + OUTLET_IN_USE = "26" + PAIR_SETUP = "4C" # new for BLE, homekit spec page 57 + PAIR_VERIFY = "4E" # new for BLE, homekit spec page 57 + PAIRING_FEATURES = "4F" # new for BLE, homekit spec page 58 + PAIRING_PAIRINGS = "50" # new for BLE, homekit spec page 58 + POSITION_CURRENT = "6D" + POSITION_HOLD = "6F" + POSITION_STATE = "72" + POSITION_TARGET = "7C" + PROGRAM_MODE = "D1" + RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD = "C9" + RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD = "CA" + RELATIVE_HUMIDITY_CURRENT = "10" + RELATIVE_HUMIDITY_TARGET = "34" + REMAINING_DURATION = "D4" + ROTATION_DIRECTION = "28" + ROTATION_SPEED = "29" + SATURATION = "2F" + SECURITY_SYSTEM_ALARM_TYPE = "8E" + SECURITY_SYSTEM_STATE_CURRENT = "66" + SECURITY_SYSTEM_STATE_TARGET = "67" + SELECTED_RTP_STREAM_CONFIGURATION = "117" + SELECTED_AUDIO_STREAM_CONFIGURATION = "128" + SERIAL_NUMBER = "30" + SERVICE_LABEL_INDEX = "CB" + SERVICE_LABEL_NAMESPACE = "CD" + SERVICE_INSTANCE_ID = ( + "e604e95d-a759-4817-87d3-aa005083a0d1".upper() + ) # new for BLE, homekit spec page 127 + SERVICE_SIGNATURE = "A5" # new for BLE, homekit spec page 128 + SET_DURATION = "D3" + SETUP_DATA_STREAM_TRANSPORT = "131" + SETUP_ENDPOINTS = "118" + SIRI_INPUT_TYPE = "132" + SLAT_STATE_CURRENT = "AA" + SMOKE_DETECTED = "76" + STATUS_ACTIVE = "75" + STATUS_FAULT = "77" + STATUS_JAMMED = "78" + STATUS_LO_BATT = "79" + STATUS_TAMPERED = "7A" + STREAMING_STATUS = "120" + SUPPORTED_AUDIO_CONFIGURATION = "115" + SUPPORTED_DATA_STREAM_TRANSPORT_DATA_CONFIGURATION = "130" + SUPPORTED_RTP_CONFIGURATION = "116" + SUPPORTED_VIDEO_STREAM_CONFIGURATION = "114" + SWING_MODE = "B6" + TARGET_CONTROL_SUPPORTED_CONFIGURATION = "123" + TARGET_CONTROL_LIST = "124" + TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE = "B4" + TEMPERATURE_COOLING_THRESHOLD = "D" + TEMPERATURE_CURRENT = "11" + TEMPERATURE_HEATING_THRESHOLD = "12" + TEMPERATURE_TARGET = "35" + TEMPERATURE_UNITS = "36" + TILT_CURRENT = "C1" + TILT_TARGET = "C2" + TYPE_SLAT = "C0" + VALVE_TYPE = "D5" + VERSION = "37" + VERTICAL_TILT_CURRENT = "6E" + VERTICAL_TILT_TARGET = "7D" + VOLUME = "119" + WATER_LEVEL = "B5" + ZOOM_DIGITAL = "11D" + ZOOM_OPTICAL = "11C" + + def __init__(self): + self.baseUUID = "-0000-1000-8000-0026BB765291" + self._characteristics = { + "1": "public.hap.characteristic.administrator-only-access", + "5": "public.hap.characteristic.audio-feedback", + "8": "public.hap.characteristic.brightness", + "D": "public.hap.characteristic.temperature.cooling-threshold", + "E": "public.hap.characteristic.door-state.current", + "F": "public.hap.characteristic.heating-cooling.current", + "10": "public.hap.characteristic.relative-humidity.current", + "11": "public.hap.characteristic.temperature.current", + "12": "public.hap.characteristic.temperature.heating-threshold", + "13": "public.hap.characteristic.hue", + "14": "public.hap.characteristic.identify", + "1A": "public.hap.characteristic.lock-management.auto-secure-timeout", + "1C": "public.hap.characteristic.lock-mechanism.last-known-action", + "1D": "public.hap.characteristic.lock-mechanism.current-state", + "1E": "public.hap.characteristic.lock-mechanism.target-state", + "1F": "public.hap.characteristic.logs", + "19": "public.hap.characteristic.lock-management.control-point", + "20": "public.hap.characteristic.manufacturer", + "21": "public.hap.characteristic.model", + "22": "public.hap.characteristic.motion-detected", + "23": "public.hap.characteristic.name", + "24": "public.hap.characteristic.obstruction-detected", + "25": "public.hap.characteristic.on", + "26": "public.hap.characteristic.outlet-in-use", + "28": "public.hap.characteristic.rotation.direction", + "29": "public.hap.characteristic.rotation.speed", + "2F": "public.hap.characteristic.saturation", + "30": "public.hap.characteristic.serial-number", + "32": "public.hap.characteristic.door-state.target", + "33": "public.hap.characteristic.heating-cooling.target", + "34": "public.hap.characteristic.relative-humidity.target", + "35": "public.hap.characteristic.temperature.target", + "36": "public.hap.characteristic.temperature.units", + "37": "public.hap.characteristic.version", + "4C": "public.hap.characteristic.pairing.pair-setup", # new for BLE, homekit spec page 57 + "4E": "public.hap.characteristic.pairing.pair-verify", # new for BLE, homekit spec page 57 + "4F": "public.hap.characteristic.pairing.features", # new for BLE, homekit spec page 58 + "50": "public.hap.characteristic.pairing.pairings", # new for BLE, homekit spec page 58 + # new for BLE, homekit spec page 127 + "e604e95d-a759-4817-87d3-aa005083a0d1".upper(): "public.hap.service.protocol.service-id", + "52": "public.hap.characteristic.firmware.revision", + "53": "public.hap.characteristic.hardware.revision", + "64": "public.hap.characteristic.air-particulate.density", + "65": "public.hap.characteristic.air-particulate.size", + "66": "public.hap.characteristic.security-system-state.current", + "67": "public.hap.characteristic.security-system-state.target", + "68": "public.hap.characteristic.battery-level", + "69": "public.hap.characteristic.carbon-monoxide.detected", + "6A": "public.hap.characteristic.contact-state", + "6B": "public.hap.characteristic.light-level.current", + "6C": "public.hap.characteristic.horizontal-tilt.current", + "6D": "public.hap.characteristic.position.current", + "6E": "public.hap.characteristic.vertical-tilt.current", + "6F": "public.hap.characteristic.position.hold", + "70": "public.hap.characteristic.leak-detected", + "71": "public.hap.characteristic.occupancy-detected", + "72": "public.hap.characteristic.position.state", + "73": "public.hap.characteristic.input-event", + "75": "public.hap.characteristic.status-active", + "76": "public.hap.characteristic.smoke-detected", + "77": "public.hap.characteristic.status-fault", + "78": "public.hap.characteristic.status-jammed", + "79": "public.hap.characteristic.status-lo-batt", + "7A": "public.hap.characteristic.status-tampered", + "7B": "public.hap.characteristic.horizontal-tilt.target", + "7C": "public.hap.characteristic.position.target", + "7D": "public.hap.characteristic.vertical-tilt.target", + "8E": "public.hap.characteristic.security-system.alarm-type", + "8F": "public.hap.characteristic.charging-state", + "90": "public.hap.characteristic.carbon-monoxide.level", + "91": "public.hap.characteristic.carbon-monoxide.peak-level", + "92": "public.hap.characteristic.carbon-dioxide.detected", + "93": "public.hap.characteristic.carbon-dioxide.level", + "94": "public.hap.characteristic.carbon-dioxide.peak-level", + "95": "public.hap.characteristic.air-quality", + "A5": "public.hap.characteristic.service-signature", + "A6": "public.hap.characteristic.accessory-properties", + "A7": "public.hap.characteristic.lock-physical-controls", + "A8": "public.hap.characteristic.air-purifier.state.target", + "A9": "public.hap.characteristic.air-purifier.state.current", + "AA": "public.hap.characteristic.slat.state.current", + "AB": "public.hap.characteristic.filter.life-level", + "AC": "public.hap.characteristic.filter.change-indication", + "AD": "public.hap.characteristic.filter.reset-indication", + "AF": "public.hap.characteristic.fan.state.current", + "B0": "public.hap.characteristic.active", + "B1": "public.hap.characteristic.heater-cooler.state.current", + "B3": "public.hap.characteristic.humidifier-dehumidifier.state.current", + "B4": "public.hap.characteristic.humidifier-dehumidifier.state.target", + "B5": "public.hap.characteristic.water-level", + "B6": "public.hap.characteristic.swing-mode", + "BF": "public.hap.characteristic.fan.state.target", + "C0": "public.hap.characteristic.type.slat", + "C1": "public.hap.characteristic.tilt.current", + "C2": "public.hap.characteristic.tilt.target", + "C3": "public.hap.characteristic.density.ozone", + "C4": "public.hap.characteristic.density.no2", + "C5": "public.hap.characteristic.density.so2", + "C6": "public.hap.characteristic.density.pm25", + "C7": "public.hap.characteristic.density.pm10", + "C8": "public.hap.characteristic.density.voc", + "C9": "public.hap.characteristic.relative-humidity.dehumidifier-threshold", + "CA": "public.hap.characteristic.relative-humidity.humidifier-threshold", + "CB": "public.hap.characteristic.service-label-index", + "CD": "public.hap.characteristic.service-label-namespace", + "CE": "public.hap.characteristic.color-temperature", + "D1": "public.hap.characteristic.program-mode", + "D2": "public.hap.characteristic.in-use", + "D3": "public.hap.characteristic.set-duration", + "D4": "public.hap.characteristic.remaining-duration", + "D5": "public.hap.characteristic.valve-type", + "D6": "public.hap.characteristic.is-configured", + "E7": "public.hap.characteristic.active-identifier", + "114": "public.hap.characteristic.supported-video-stream-configuration", + "115": "public.hap.characteristic.supported-audio-configuration", + "116": "public.hap.characteristic.supported-rtp-configuration", + "117": "public.hap.characteristic.selected-rtp-stream-configuration", + "118": "public.hap.characteristic.setup-endpoints", + "119": "public.hap.characteristic.volume", + "11A": "public.hap.characteristic.mute", + "11B": "public.hap.characteristic.night-vision", + "11C": "public.hap.characteristic.zoom-optical", + "11D": "public.hap.characteristic.zoom-digital", + "11E": "public.hap.characteristic.image-rotation", + "11F": "public.hap.characteristic.image-mirror", + "120": "public.hap.characteristic.streaming-status", + "123": "public.hap.characteristic.supported-target-configuration", + "124": "public.hap.characteristic.target-list", + "126": "public.hap.characteristic.button-event", + "128": "public.hap.characteristic.selected-audio-stream-configuration", + "130": "public.hap.characteristic.supported-data-stream-transport-configuration", + "131": "public.hap.characteristic.setup-data-stream-transport", + "132": "public.hap.characteristic.siri-input-type", + } + + self._characteristics_rev = { + self._characteristics[k]: k for k in self._characteristics.keys() + } + + def __getitem__(self, item): + if item in self._characteristics: + return self._characteristics[item] + + if item in self._characteristics_rev: + return self._characteristics_rev[item] + + # https://docs.python.org/3.5/reference/datamodel.html#object.__getitem__ say, KeyError should be raised + raise KeyError("Unknown Characteristic {i}?".format(i=item)) + + def get_short(self, uuid: str): + """ + Returns the short type for a given UUID. That means that "0000006D-0000-1000-8000-0026BB765291" and "6D" both + translates to "position.current" (after looking up "public.hap.characteristic.position.current"). + + if item in self._characteristics: + return self._characteristics[item].split('.', 3)[3] + :param uuid: the UUID in long form or the shortened version as defined in chapter 5.6.1 page 72. + :return: the textual representation + """ + orig_item = uuid + uuid = uuid.upper() + if uuid.endswith(self.baseUUID): + uuid = uuid.split("-", 1)[0] + uuid = uuid.lstrip("0") + + if uuid in self._characteristics: + return self._characteristics[uuid].split(".", maxsplit=3)[3] + + return "Unknown Characteristic {i}".format(i=orig_item) + + def get_short_uuid(self, item_name): + """ + Returns the short UUID for either a full UUID or textual characteristic type name. For information on + full and short UUID consult chapter 5.6.1 page 72 of the specification. It also supports to pass through full + non-HomeKit UUIDs. + + :param item_name: either the type name (e.g. "public.hap.characteristic.position.current") or the short UUID as + string or a HomeKit specific full UUID. + :return: the short UUID (e.g. "6D" instead of "0000006D-0000-1000-8000-0026BB765291") + :raises KeyError: if the input is neither a UUID nor a type name. Specific error is given in the message. + """ + orig_item = item_name + if item_name.upper().endswith(self.baseUUID): + item_name = item_name.upper() + item_name = item_name.split("-", 1)[0] + return item_name.lstrip("0") + + if item_name.upper() in self._characteristics: + item_name = item_name.upper() + return item_name + + if item_name.lower() in self._characteristics_rev: + item_name = item_name.lower() + return self._characteristics_rev[item_name] + + try: + uuid.UUID("{{{s}}}".format(s=item_name)) + return item_name + except ValueError: + raise KeyError("No short UUID found for Item {item}".format(item=orig_item)) + + def get_uuid(self, item_name): + """ + Returns the full length UUID for either a shorted UUID or textual characteristic type name. For information on + full and short UUID consult chapter 5.6.1 page 72 of the specification. It also supports to pass through full + HomeKit UUIDs. + + Shorted UUID means also leading zeros are stripped. + + :param item_name: either the type name (e.g. "public.hap.characteristic.position.current") or the short UUID or + a HomeKit specific full UUID. + :return: the full UUID (e.g. "0000006D-0000-1000-8000-0026BB765291") + :raises KeyError: if the input is neither a short UUID nor a type name. Specific error is given in the message. + """ + orig_item = item_name + # if we get a full length uuid with the proper base and a known short one, this should also work. + if item_name.upper().endswith(self.baseUUID): + item_name = item_name.upper() + item_name = item_name.split("-", 1)[0] + item_name = item_name.lstrip("0") + + if item_name.lower() in self._characteristics_rev: + short = self._characteristics_rev[item_name.lower()] + elif item_name.upper() in self._characteristics: + short = item_name.upper() + else: + raise KeyError("No UUID found for Item {item}".format(item=orig_item)) + + medium = "0" * (8 - len(short)) + short + long = medium + self.baseUUID + return long + + +# +# Have a singleton to avoid overhead +# +CharacteristicsTypes = _CharacteristicsTypes() diff --git a/homekit/model/characteristics/characteristic_units.py b/homekit/model/characteristics/characteristic_units.py new file mode 100644 index 00000000..7fc90a19 --- /dev/null +++ b/homekit/model/characteristics/characteristic_units.py @@ -0,0 +1,52 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class CharacteristicUnits(object): + """ + See table 5-6 page 68 + """ + + celsius = "celsius" + percentage = "percentage" + arcdegrees = "arcdegrees" + lux = "lux" + seconds = "seconds" + + +class _BleCharacteristicUnits(object): + """ + Mapping taken from Table 6-37 page 130 and https://www.bluetooth.com/specifications/assigned-numbers/units + """ + + def __init__(self): + self._formats = { + 0x272F: "celsius", + 0x2763: "arcdegrees", + 0x27AD: "percentage", + 0x2700: "unitless", + 0x2731: "lux", + 0x2703: "seconds", + } + + def get(self, key, default): + return self._formats.get(key, default) + + +# +# Have a singleton to avoid overhead +# +BleCharacteristicUnits = _BleCharacteristicUnits() diff --git a/homekit/model/feature_flags.py b/homekit/model/feature_flags.py new file mode 100644 index 00000000..d1f6276f --- /dev/null +++ b/homekit/model/feature_flags.py @@ -0,0 +1,34 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _FeatureFlags(object): + """ + Data taken form table 5-8 Bonjour TXT Record Feature Flags on page 69. + """ + + def __init__(self): + self._data = {0: "No support for HAP Pairing", 1: "Supports HAP Pairing"} + + def __getitem__(self, item): + bit_value = item & 0x01 + if bit_value in self._data: + return self._data[bit_value] + + raise KeyError("Item {item} not found".format(item=item)) + + +FeatureFlags = _FeatureFlags() diff --git a/homekit/model/mixin.py b/homekit/model/mixin.py new file mode 100644 index 00000000..1c46bf9b --- /dev/null +++ b/homekit/model/mixin.py @@ -0,0 +1,56 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 + +id_counter = 0 + + +def get_id(): + global id_counter + id_counter += 1 + return id_counter + + +class ToDictMixin(object): + """ + Will help to convert the various accessories, services and characteristics to JSON. + """ + + def _to_dict(self): + tmp = {} + for x in dir(self): + if x.startswith("_") or callable(getattr(self, x)): + continue + val = getattr(self, x) + if val is None: + continue + if isinstance(val, list): + tmpval = [] + for e in val: + if isinstance(e, str): + tmpval.append(e) + else: + tmpval.append(e._to_dict()) + tmp[x] = tmpval + else: + tmp[x] = val + + return tmp + + def __str__(self): + d = self._to_dict() + return json.dumps(d) diff --git a/homekit/model/services/__init__.py b/homekit/model/services/__init__.py new file mode 100644 index 00000000..f4aa14bc --- /dev/null +++ b/homekit/model/services/__init__.py @@ -0,0 +1,38 @@ +# +# Copyright 2019 aiohomekit team +# +# 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__ = [ + "ThermostatService", + "LightBulbService", + "FanService", + "BHSLightBulbService", + "AccessoryInformationService", + "OutletService", + "AbstractService", + "ServicesTypes", +] + +from homekit.model.services.service_types import ServicesTypes + +from homekit.model.services.abstract_service import AbstractService +from homekit.model.services.accessoryinformation_service import ( + AccessoryInformationService, +) +from homekit.model.services.bhslightbulb_service import BHSLightBulbService +from homekit.model.services.fan_service import FanService +from homekit.model.services.lightbulb_service import LightBulbService +from homekit.model.services.outlet_service import OutletService +from homekit.model.services.thermostat_service import ThermostatService diff --git a/homekit/model/services/abstract_service.py b/homekit/model/services/abstract_service.py new file mode 100644 index 00000000..6873ae59 --- /dev/null +++ b/homekit/model/services/abstract_service.py @@ -0,0 +1,47 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 homekit.model import ToDictMixin + + +class AbstractService(ToDictMixin): + def __init__(self, service_type: str, iid: int): + if type(self) is AbstractService: + raise Exception( + "AbstractService is an abstract class and cannot be instantiated directly" + ) + self.type = service_type + self.iid = iid + self.characteristics = [] + + def append_characteristic(self, characteristic): + """ + Append the given characteristic to the service. + + :param characteristic: a subclass of AbstractCharacteristic + """ + self.characteristics.append(characteristic) + + def to_accessory_and_service_list(self): + characteristics_list = [] + for c in self.characteristics: + characteristics_list.append(c.to_accessory_and_service_list()) + d = { + "iid": self.iid, + "type": self.type, + "characteristics": characteristics_list, + } + return d diff --git a/homekit/model/services/service_types.py b/homekit/model/services/service_types.py new file mode 100644 index 00000000..3bd7b736 --- /dev/null +++ b/homekit/model/services/service_types.py @@ -0,0 +1,143 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _ServicesTypes(object): + """ + This data is taken from chapter 9 page 216 onwards. + """ + + INFORMATION_SERVICE = "A2" # new for ble, homekit spec page 126 + PAIRING_SERVICE = "55" # new for ble, homekit spec page 57 + ACCESSORY_INFORMATION_SERVICE = "3E" + BATTERY_SERVICE = "96" + + def __init__(self): + self.baseUUID = "-0000-1000-8000-0026BB765291" + self._services = { + "3E": "public.hap.service.accessory-information", + "40": "public.hap.service.fan", + "41": "public.hap.service.garage-door-opener", + "43": "public.hap.service.lightbulb", + "44": "public.hap.service.lock-management", + "45": "public.hap.service.lock-mechanism", + "47": "public.hap.service.outlet", + "49": "public.hap.service.switch", + "4A": "public.hap.service.thermostat", + "55": "public.hap.service.pairing", # new for ble, homekit spec page 57 + "7E": "public.hap.service.security-system", + "7F": "public.hap.service.sensor.carbon-monoxide", + "80": "public.hap.service.sensor.contact", + "81": "public.hap.service.door", + "82": "public.hap.service.sensor.humidity", + "83": "public.hap.service.sensor.leak", + "84": "public.hap.service.sensor.light", + "85": "public.hap.service.sensor.motion", + "86": "public.hap.service.sensor.occupancy", + "87": "public.hap.service.sensor.smoke", + "89": "public.hap.service.stateless-programmable-switch", + "8A": "public.hap.service.sensor.temperature", + "8B": "public.hap.service.window", + "8C": "public.hap.service.window-covering", + "8D": "public.hap.service.sensor.air-quality", + "96": "public.hap.service.battery", + "97": "public.hap.service.sensor.carbon-dioxide", + "A2": "public.hap.service.protocol.information.service", # new for ble, homekit spec page 126 + "B7": "public.hap.service.fanv2", + "B9": "public.hap.service.vertical-slat", + "BA": "public.hap.service.filter-maintenance", + "BB": "public.hap.service.air-purifier", + "BC": "public.hap.service.heater-cooler", + "BD": "public.hap.service.humidifier-dehumidifier", + "CC": "public.hap.service.service-label", + "CF": "public.hap.service.irrigation-system", + "D0": "public.hap.service.valve", + "D7": "public.hap.service.faucet", + "110": "public.hap.service.camera-rtp-stream-management", + "112": "public.hap.service.microphone", + "113": "public.hap.service.speaker", + "121": "public.hap.service.doorbell", + "122": "public.hap.service.target-control-management", + "125": "public.hap.service.target-control", + "127": "public.hap.service.audio-stream-management", + "129": "public.hap.service.data-stream-transport-management", + "133": "public.hap.service.siri", + } + + self._services_rev = {self._services[k]: k for k in self._services.keys()} + + def __getitem__(self, item): + if item in self._services: + return self._services[item] + + if item in self._services_rev: + return self._services_rev[item] + + # raise KeyError('Item {item} not found'.format_map(item=item)) + return "Unknown Service: {i}".format(i=item) + + def get_short(self, item): + """ + get the short version of the service name (aka the last segment of the name) or if this is not in the list of + services it returns 'Unknown Service: XX'. + + :param item: the items full UUID + :return: the last segment of the service name or a hint that it is unknown + """ + orig_item = item + item = item.upper() + if item.endswith(self.baseUUID): + item = item.split("-", 1)[0] + item = item.lstrip("0") + + if item in self._services: + return self._services[item].split(".")[-1] + return "Unknown Service: {i}".format(i=orig_item) + + def get_uuid(self, item_name): + """ + Returns the full length UUID for either a shorted UUID or textual characteristic type name. For information on + full and short UUID consult chapter 5.6.1 page 72 of the specification. It also supports to pass through full + HomeKit UUIDs. + + :param item_name: either the type name (e.g. "public.hap.characteristic.position.current") or the short UUID or + a HomeKit specific full UUID. + :return: the full UUID (e.g. "0000006D-0000-1000-8000-0026BB765291") + :raises KeyError: if the input is neither a short UUID nor a type name. Specific error is given in the message. + """ + orig_item = item_name + # if we get a full length uuid with the proper base and a known short one, this should also work. + if item_name.upper().endswith(self.baseUUID): + item_name = item_name.upper() + item_name = item_name.split("-", 1)[0] + item_name = item_name.lstrip("0") + + if item_name.lower() in self._services_rev: + short = self._services_rev[item_name.lower()] + elif item_name.upper() in self._services: + short = item_name.upper() + else: + raise KeyError("No UUID found for Item {item}".format(item=orig_item)) + + medium = "0" * (8 - len(short)) + short + long = medium + self.baseUUID + return long + + +# +# Have a singleton to avoid overhead +# +ServicesTypes = _ServicesTypes() diff --git a/homekit/model/status_flags.py b/homekit/model/status_flags.py new file mode 100644 index 00000000..265d5771 --- /dev/null +++ b/homekit/model/status_flags.py @@ -0,0 +1,63 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _IpStatusFlags(object): + """ + Data taken form table 5-9 page 70 + """ + + def __getitem__(self, item): + i = int(item) + result = [] + if i & 0x01: + result.append("Accessory has not been paired with any controllers.") + i = i - 0x01 + else: + result.append("Accessory has been paired.") + if i & 0x02: + result.append("Accessory has not been configured to join a Wi-Fi network.") + i = i - 0x02 + if i & 0x04: + result.append("A problem has been detected on the accessory.") + i = i - 0x04 + if i == 0: + return " ".join(result) + else: + raise KeyError("Item {item} not found".format(item=item)) + + +class _BleStatusFlags(object): + """ + Data taken form table 6-32 page 125 + """ + + def __getitem__(self, item): + i = int(item) + result = [] + if i & 0x01: + result.append("The accessory has not been paired with any controllers.") + i = i - 0x01 + else: + result.append("The accessory has been paired with a controllers.") + if i == 0: + return " ".join(result) + else: + raise KeyError("Item {item} not found".format(item=item)) + + +IpStatusFlags = _IpStatusFlags() +BleStatusFlags = _BleStatusFlags() diff --git a/homekit/protocol/__init__.py b/homekit/protocol/__init__.py new file mode 100644 index 00000000..d9528671 --- /dev/null +++ b/homekit/protocol/__init__.py @@ -0,0 +1,461 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 hashlib +import ed25519 +import hkdf +from binascii import hexlify +import logging + +from cryptography.hazmat.primitives.asymmetric import x25519 +from cryptography.hazmat.primitives import serialization + +from homekit.protocol.tlv import TLV +from homekit.exceptions import ( + IncorrectPairingIdError, + InvalidAuthTagError, + InvalidSignatureError, + UnavailableError, + AuthenticationError, + InvalidError, + BusyError, + MaxTriesError, + MaxPeersError, + BackoffError, +) + +import homekit.exceptions +from homekit.crypto import chacha20_aead_decrypt, chacha20_aead_encrypt, SrpClient + + +def error_handler(error, stage): + """ + Transform the various error messages defined in table 4-5 page 60 into exceptions + + :param error: the kind of error + :param stage: the stage it appeared in + :return: None + """ + if error == TLV.kTLVError_Unavailable: + raise UnavailableError(stage) + elif error == TLV.kTLVError_Authentication: + raise AuthenticationError(stage) + elif error == TLV.kTLVError_Backoff: + raise BackoffError(stage) + elif error == TLV.kTLVError_MaxPeers: + raise MaxPeersError(stage) + elif error == TLV.kTLVError_MaxTries: + raise MaxTriesError(stage) + elif error == TLV.kTLVError_Busy: + raise BusyError(stage) + else: + raise InvalidError(stage) + + +def create_ip_pair_setup_write(connection): + def write_http(request, expected): + body = TLV.encode_list(request) + logging.debug("write message: %s", TLV.to_string(TLV.decode_bytes(body))) + connection.putrequest("POST", "/pair-setup", skip_accept_encoding=True) + connection.putheader("Content-Type", "application/pairing+tlv8") + connection.putheader("Content-Length", len(body)) + connection.endheaders(body) + resp = connection.getresponse() + response_tlv = TLV.decode_bytes(resp.read(), expected) + logging.debug("response: %s", TLV.to_string(response_tlv)) + return response_tlv + + return write_http + + +def create_ip_pair_verify_write(connection): + def write_http(request, expected): + body = TLV.encode_list(request) + logging.debug("write message: %s", TLV.to_string(TLV.decode_bytes(body))) + connection.putrequest("POST", "/pair-verify", skip_accept_encoding=True) + connection.putheader("Content-Type", "application/pairing+tlv8") + connection.putheader("Content-Length", len(body)) + connection.endheaders(body) + resp = connection.getresponse() + response_tlv = TLV.decode_bytes(resp.read(), expected) + logging.debug("response: %s", TLV.to_string(response_tlv)) + return response_tlv + + return write_http + + +def perform_pair_setup_part1(): + """ + Performs a pair setup operation as described in chapter 4.7 page 39 ff. + + :return: a tuple of salt and server's public key + :raises UnavailableError: if the device is already paired + :raises MaxTriesError: if the device received more than 100 unsuccessful pairing attempts + :raises BusyError: if a parallel pairing is ongoing + :raises AuthenticationError: if the verification of the device's SRP proof fails + :raises MaxPeersError: if the device cannot accept an additional pairing + :raises IllegalData: if the verification of the accessory's data fails + """ + + # + # Step #1 ios --> accessory (send SRP start Request) (see page 39) + # + logging.debug("#1 ios -> accessory: send SRP start request") + request_tlv = [(TLV.kTLVType_State, TLV.M1), (TLV.kTLVType_Method, TLV.PairSetup)] + + step2_expectations = [ + TLV.kTLVType_State, + TLV.kTLVType_Error, + TLV.kTLVType_PublicKey, + TLV.kTLVType_Salt, + ] + response_tlv = yield (request_tlv, step2_expectations) + + # + # Step #3 ios --> accessory (send SRP verify request) (see page 41) + # + logging.debug("#3 ios -> accessory: send SRP verify request") + response_tlv = TLV.reorder(response_tlv, step2_expectations) + assert ( + response_tlv[0][0] == TLV.kTLVType_State and response_tlv[0][1] == TLV.M2 + ), "perform_pair_setup: State not M2" + + # the errors here can be: + # * kTLVError_Unavailable: Device is paired + # * kTLVError_MaxTries: More than 100 unsuccessful attempts + # * kTLVError_Busy: There is already a pairing going on + if response_tlv[1][0] == TLV.kTLVType_Error: + error_handler(response_tlv[1][1], "step 3") + + assert ( + response_tlv[1][0] == TLV.kTLVType_PublicKey + ), "perform_pair_setup: Not a public key" + assert response_tlv[2][0] == TLV.kTLVType_Salt, "perform_pair_setup: Not a salt" + + return response_tlv[2][1], response_tlv[1][1] + + +def perform_pair_setup_part2(pin, ios_pairing_id, salt, server_public_key): + """ + Performs a pair setup operation as described in chapter 4.7 page 39 ff. + + :param pin: the setup code from the accessory + :param ios_pairing_id: the id of the simulated ios device + :return: a dict with the ios device's part of the pairing information + :raises UnavailableError: if the device is already paired + :raises MaxTriesError: if the device received more than 100 unsuccessful pairing attempts + :raises BusyError: if a parallel pairing is ongoing + :raises AuthenticationError: if the verification of the device's SRP proof fails + :raises MaxPeersError: if the device cannot accept an additional pairing + :raises IllegalData: if the verification of the accessory's data fails + """ + + srp_client = SrpClient("Pair-Setup", pin) + srp_client.set_salt(salt) + srp_client.set_server_public_key(server_public_key) + client_pub_key = srp_client.get_public_key() + client_proof = srp_client.get_proof() + + response_tlv = [ + (TLV.kTLVType_State, TLV.M3), + (TLV.kTLVType_PublicKey, SrpClient.to_byte_array(client_pub_key)), + (TLV.kTLVType_Proof, SrpClient.to_byte_array(client_proof)), + ] + + step4_expectations = [TLV.kTLVType_State, TLV.kTLVType_Error, TLV.kTLVType_Proof] + response_tlv = yield (response_tlv, step4_expectations) + + # + # Step #5 ios --> accessory (Exchange Request) (see page 43) + # + logging.debug("#5 ios -> accessory: send SRP exchange request") + + # M4 Verification (page 43) + response_tlv = TLV.reorder(response_tlv, step4_expectations) + assert ( + response_tlv[0][0] == TLV.kTLVType_State and response_tlv[0][1] == TLV.M4 + ), "perform_pair_setup: State not M4" + if response_tlv[1][0] == TLV.kTLVType_Error: + error_handler(response_tlv[1][1], "step 5") + + assert response_tlv[1][0] == TLV.kTLVType_Proof, "perform_pair_setup: Not a proof" + if not srp_client.verify_servers_proof(response_tlv[1][1]): + raise AuthenticationError("Step #5: wrong proof!") + + # M5 Request generation (page 44) + session_key = srp_client.get_session_key() + + ios_device_ltsk, ios_device_ltpk = ed25519.create_keypair() + + # reversed: + # Pair-Setup-Encrypt-Salt instead of Pair-Setup-Controller-Sign-Salt + # Pair-Setup-Encrypt-Info instead of Pair-Setup-Controller-Sign-Info + hkdf_inst = hkdf.Hkdf( + "Pair-Setup-Controller-Sign-Salt".encode(), + SrpClient.to_byte_array(session_key), + hash=hashlib.sha512, + ) + ios_device_x = hkdf_inst.expand("Pair-Setup-Controller-Sign-Info".encode(), 32) + + hkdf_inst = hkdf.Hkdf( + "Pair-Setup-Encrypt-Salt".encode(), + SrpClient.to_byte_array(session_key), + hash=hashlib.sha512, + ) + session_key = hkdf_inst.expand("Pair-Setup-Encrypt-Info".encode(), 32) + + ios_device_pairing_id = ios_pairing_id.encode() + ios_device_info = ios_device_x + ios_device_pairing_id + ios_device_ltpk.to_bytes() + + ios_device_signature = ios_device_ltsk.sign(ios_device_info) + + sub_tlv = [ + (TLV.kTLVType_Identifier, ios_device_pairing_id), + (TLV.kTLVType_PublicKey, ios_device_ltpk.to_bytes()), + (TLV.kTLVType_Signature, ios_device_signature), + ] + sub_tlv_b = TLV.encode_list(sub_tlv) + + # taking tge iOSDeviceX as key was reversed from + # https://github.com/KhaosT/HAP-NodeJS/blob/2ea9d761d9bd7593dd1949fec621ab085af5e567/lib/HAPServer.js + # function handlePairStepFive calling encryption.encryptAndSeal + encrypted_data_with_auth_tag = chacha20_aead_encrypt( + bytes(), session_key, "PS-Msg05".encode(), bytes([0, 0, 0, 0]), sub_tlv_b + ) + tmp = bytearray(encrypted_data_with_auth_tag[0]) + tmp += encrypted_data_with_auth_tag[1] + + response_tlv = [(TLV.kTLVType_State, TLV.M5), (TLV.kTLVType_EncryptedData, tmp)] + + step6_expectations = [ + TLV.kTLVType_State, + TLV.kTLVType_Error, + TLV.kTLVType_EncryptedData, + ] + response_tlv = yield (response_tlv, step6_expectations) + + # + # Step #7 ios (Verification) (page 47) + # + response_tlv = TLV.reorder(response_tlv, step6_expectations) + assert ( + response_tlv[0][0] == TLV.kTLVType_State and response_tlv[0][1] == TLV.M6 + ), "perform_pair_setup: State not M6" + if response_tlv[1][0] == TLV.kTLVType_Error: + error_handler(response_tlv[1][1], "step 7") + + assert ( + response_tlv[1][0] == TLV.kTLVType_EncryptedData + ), "perform_pair_setup: No encrypted data" + decrypted_data = chacha20_aead_decrypt( + bytes(), + session_key, + "PS-Msg06".encode(), + bytes([0, 0, 0, 0]), + response_tlv[1][1], + ) + if decrypted_data is False: + raise homekit.exception.IllegalData("step 7") + + response_tlv = TLV.decode_bytearray(decrypted_data) + response_tlv = TLV.reorder( + response_tlv, + [TLV.kTLVType_Identifier, TLV.kTLVType_PublicKey, TLV.kTLVType_Signature], + ) + + assert ( + response_tlv[2][0] == TLV.kTLVType_Signature + ), "perform_pair_setup: No signature" + accessory_sig = response_tlv[2][1] + + assert ( + response_tlv[0][0] == TLV.kTLVType_Identifier + ), "perform_pair_setup: No identifier" + accessory_pairing_id = response_tlv[0][1] + + assert ( + response_tlv[1][0] == TLV.kTLVType_PublicKey + ), "perform_pair_setup: No public key" + accessory_ltpk = response_tlv[1][1] + + hkdf_inst = hkdf.Hkdf( + "Pair-Setup-Accessory-Sign-Salt".encode(), + SrpClient.to_byte_array(srp_client.get_session_key()), + hash=hashlib.sha512, + ) + accessory_x = hkdf_inst.expand("Pair-Setup-Accessory-Sign-Info".encode(), 32) + + accessory_info = accessory_x + accessory_pairing_id + accessory_ltpk + + e25519s = ed25519.VerifyingKey(bytes(response_tlv[1][1])) + try: + e25519s.verify(bytes(accessory_sig), bytes(accessory_info)) + except AssertionError: + raise InvalidSignatureError("step #7") + + return { + "AccessoryPairingID": response_tlv[0][1].decode(), + "AccessoryLTPK": hexlify(response_tlv[1][1]).decode(), + "iOSPairingId": ios_pairing_id, + "iOSDeviceLTSK": ios_device_ltsk.to_ascii(encoding="hex").decode()[:64], + "iOSDeviceLTPK": hexlify(ios_device_ltpk.to_bytes()).decode(), + } + + +def get_session_keys(pairing_data): + """ + HomeKit Controller state machine to perform a pair verify operation as described in chapter 4.8 page 47 ff. + :param pairing_data: the paring data as returned by perform_pair_setup + :return: tuple of the session keys (controller_to_accessory_key and accessory_to_controller_key) + :raises InvalidAuthTagError: if the auth tag could not be verified, + :raises IncorrectPairingIdError: if the accessory's LTPK could not be found + :raises InvalidSignatureError: if the accessory's signature could not be verified + :raises AuthenticationError: if the secured session could not be established + """ + + # + # Step #1 ios --> accessory (send verify start Request) (page 47) + # + ios_key = x25519.X25519PrivateKey.generate() + ios_key_pub = ios_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + + request_tlv = [(TLV.kTLVType_State, TLV.M1), (TLV.kTLVType_PublicKey, ios_key_pub)] + + step2_expectations = [ + TLV.kTLVType_State, + TLV.kTLVType_PublicKey, + TLV.kTLVType_EncryptedData, + ] + response_tlv = yield (request_tlv, step2_expectations) + + # + # Step #3 ios --> accessory (send SRP verify request) (page 49) + # + response_tlv = TLV.reorder(response_tlv, step2_expectations) + assert ( + response_tlv[0][0] == TLV.kTLVType_State and response_tlv[0][1] == TLV.M2 + ), "get_session_keys: not M2" + assert ( + response_tlv[1][0] == TLV.kTLVType_PublicKey + ), "get_session_keys: no public key" + assert ( + response_tlv[2][0] == TLV.kTLVType_EncryptedData + ), "get_session_keys: no encrypted data" + + # 1) generate shared secret + accessorys_session_pub_key_bytes = bytes(response_tlv[1][1]) + accessorys_session_pub_key = x25519.X25519PublicKey.from_public_bytes( + accessorys_session_pub_key_bytes + ) + shared_secret = ios_key.exchange(accessorys_session_pub_key) + + # 2) derive session key + hkdf_inst = hkdf.Hkdf( + "Pair-Verify-Encrypt-Salt".encode(), shared_secret, hash=hashlib.sha512 + ) + session_key = hkdf_inst.expand("Pair-Verify-Encrypt-Info".encode(), 32) + + # 3) verify auth tag on encrypted data and 4) decrypt + encrypted = response_tlv[2][1] + decrypted = chacha20_aead_decrypt( + bytes(), session_key, "PV-Msg02".encode(), bytes([0, 0, 0, 0]), encrypted + ) + if type(decrypted) == bool and not decrypted: + raise InvalidAuthTagError("step 3") + d1 = TLV.decode_bytes(decrypted) + d1 = TLV.reorder(d1, [TLV.kTLVType_Identifier, TLV.kTLVType_Signature]) + assert d1[0][0] == TLV.kTLVType_Identifier, "get_session_keys: no identifier" + assert d1[1][0] == TLV.kTLVType_Signature, "get_session_keys: no signature" + + # 5) look up pairing by accessory name + accessory_name = d1[0][1].decode() + + if pairing_data["AccessoryPairingID"] != accessory_name: + raise IncorrectPairingIdError("step 3") + + accessory_ltpk = ed25519.VerifyingKey(bytes.fromhex(pairing_data["AccessoryLTPK"])) + + # 6) verify accessory's signature + accessory_sig = d1[1][1] + accessory_session_pub_key_bytes = response_tlv[1][1] + accessory_info = ( + accessory_session_pub_key_bytes + accessory_name.encode() + ios_key_pub + ) + try: + accessory_ltpk.verify(bytes(accessory_sig), bytes(accessory_info)) + except ed25519.BadSignatureError: + raise InvalidSignatureError("step 3") + + # 7) create iOSDeviceInfo + ios_device_info = ( + ios_key_pub + + pairing_data["iOSPairingId"].encode() + + accessorys_session_pub_key_bytes + ) + + # 8) sign iOSDeviceInfo with long term secret key + ios_device_ltsk_h = pairing_data["iOSDeviceLTSK"] + ios_device_ltpk_h = pairing_data["iOSDeviceLTPK"] + ios_device_ltsk = ed25519.SigningKey( + bytes.fromhex(ios_device_ltsk_h) + bytes.fromhex(ios_device_ltpk_h) + ) + ios_device_signature = ios_device_ltsk.sign(ios_device_info) + + # 9) construct sub tlv + sub_tlv = TLV.encode_list( + [ + (TLV.kTLVType_Identifier, pairing_data["iOSPairingId"].encode()), + (TLV.kTLVType_Signature, ios_device_signature), + ] + ) + + # 10) encrypt and sign + encrypted_data_with_auth_tag = chacha20_aead_encrypt( + bytes(), session_key, "PV-Msg03".encode(), bytes([0, 0, 0, 0]), sub_tlv + ) + tmp = bytearray(encrypted_data_with_auth_tag[0]) + tmp += encrypted_data_with_auth_tag[1] + + # 11) create tlv + request_tlv = [(TLV.kTLVType_State, TLV.M3), (TLV.kTLVType_EncryptedData, tmp)] + + step3_expectations = [TLV.kTLVType_State, TLV.kTLVType_Error] + response_tlv = yield (request_tlv, step3_expectations) + + # + # Post Step #4 verification (page 51) + # + response_tlv = TLV.reorder(response_tlv, step3_expectations) + assert ( + response_tlv[0][0] == TLV.kTLVType_State and response_tlv[0][1] == TLV.M4 + ), "get_session_keys: not M4" + if len(response_tlv) == 2 and response_tlv[1][0] == TLV.kTLVType_Error: + error_handler(response_tlv[1][1], "verification") + + # calculate session keys + hkdf_inst = hkdf.Hkdf("Control-Salt".encode(), shared_secret, hash=hashlib.sha512) + controller_to_accessory_key = hkdf_inst.expand( + "Control-Write-Encryption-Key".encode(), 32 + ) + + hkdf_inst = hkdf.Hkdf("Control-Salt".encode(), shared_secret, hash=hashlib.sha512) + accessory_to_controller_key = hkdf_inst.expand( + "Control-Read-Encryption-Key".encode(), 32 + ) + + return controller_to_accessory_key, accessory_to_controller_key diff --git a/homekit/protocol/opcodes.py b/homekit/protocol/opcodes.py new file mode 100644 index 00000000..602642e7 --- /dev/null +++ b/homekit/protocol/opcodes.py @@ -0,0 +1,49 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _HapBleOpCodes(object): + """ + This data is taken from Table 6-7 HAP Opcode Description on page 97. + """ + + CHAR_SIG_READ = 0x01 + CHAR_WRITE = 0x02 + CHAR_READ = 0x03 + CHAR_TIMED_WRITE = 0x04 + CHAR_EXEC_WRITE = 0x05 + SERV_SIG_READ = 0x06 + + def __init__(self): + self._codes = { + _HapBleOpCodes.CHAR_SIG_READ: "HAP-Characteristic-Signature-Read", + _HapBleOpCodes.CHAR_WRITE: "HAP-Characteristic-Write", + _HapBleOpCodes.CHAR_READ: "HAP-Characteristic-Read", + _HapBleOpCodes.CHAR_TIMED_WRITE: "HAP-Characteristic-Timed-Write", + _HapBleOpCodes.CHAR_EXEC_WRITE: "HAP-Characteristic-Execute-Write", + _HapBleOpCodes.SERV_SIG_READ: "HAP-Service-Signature-Read", + } + + self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} + + def __getitem__(self, item): + if item in self._codes: + return self._codes[item] + + raise KeyError("Item {item} not found".format(item=item)) + + +HapBleOpCodes = _HapBleOpCodes() diff --git a/homekit/protocol/statuscodes.py b/homekit/protocol/statuscodes.py new file mode 100644 index 00000000..e9b0f0c2 --- /dev/null +++ b/homekit/protocol/statuscodes.py @@ -0,0 +1,98 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +class _HapStatusCodes(object): + """ + This data is taken from Table 5-12 HAP Satus Codes on page 80. + """ + + SUCCESS = 0 + INSUFFICIENT_PRIVILEGES = -70401 + UNABLE_TO_COMMUNICATE = -70402 + RESOURCE_BUSY = -70403 + CANT_WRITE_READ_ONLY = -70404 + CANT_READ_WRITE_ONLY = -70405 + NOTIFICATION_NOT_SUPPORTED = -70406 + OUT_OF_RESOURCES = -70407 + TIMED_OUT = -70408 + RESOURCE_NOT_EXIST = -70409 + INVALID_VALUE = -70410 + INSUFFICIENT_AUTH = -70411 + + def __init__(self): + self._codes = { + _HapStatusCodes.SUCCESS: "This specifies a success for the request.", + _HapStatusCodes.INSUFFICIENT_PRIVILEGES: "Request denied due to insufficient privileges.", + _HapStatusCodes.UNABLE_TO_COMMUNICATE: "Unable to communicate with requested service, e.g. the power to the accessory was turned off.", + _HapStatusCodes.RESOURCE_BUSY: "Resource is busy, try again.", + _HapStatusCodes.CANT_WRITE_READ_ONLY: "Cannot write to read only characteristic.", + _HapStatusCodes.CANT_READ_WRITE_ONLY: "Cannot read from a write only characteristic.", + _HapStatusCodes.NOTIFICATION_NOT_SUPPORTED: "Notification is not supported for characteristic.", + _HapStatusCodes.OUT_OF_RESOURCES: "Out of resources to process request.", + _HapStatusCodes.TIMED_OUT: "Operation timed out.", + _HapStatusCodes.RESOURCE_NOT_EXIST: "Resource does not exist.", + _HapStatusCodes.INVALID_VALUE: "Accessory received an invalid value in a write request.", + _HapStatusCodes.INSUFFICIENT_AUTH: "Insufficient Authorization.", + } + + self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} + + def __getitem__(self, item): + if item in self._codes: + return self._codes[item] + + raise KeyError("Item {item} not found".format(item=item)) + + +class _HapBleStatusCodes(object): + """ + This data is taken from Table 6-26 HAP Status Codes on page 116. + """ + + SUCCESS = 0x00 + UNSUPPORTED_PDU = 0x01 + MAX_PROCEDURES = 0x02 + INSUFFICIENT_AUTHORIZATION = 0x03 + INVALID_INSTANCE_ID = 0x04 + INSUFFICIENT_AUTHENTICATION = 0x05 + INVALID_REQUEST = 0x06 + + def __init__(self): + self._codes = { + _HapBleStatusCodes.SUCCESS: "The request was successful.", + _HapBleStatusCodes.UNSUPPORTED_PDU: "The request failed as the HAP PDU was not recognized or supported.", + _HapBleStatusCodes.MAX_PROCEDURES: "The request failed as the accessory has reached the limit on" + " the simultaneous procedures it can handle.", + _HapBleStatusCodes.INSUFFICIENT_AUTHORIZATION: "Characteristic requires additional authorization data.", + _HapBleStatusCodes.INVALID_INSTANCE_ID: "The HAP Request's characteristic Instance Id did not match" + " the addressed characteristic's instance Id", + _HapBleStatusCodes.INSUFFICIENT_AUTHENTICATION: "Characterisitc access required a secure session to be" + " established.", + _HapBleStatusCodes.INVALID_REQUEST: "Accessory was not able to perform the requested operation", + } + + self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} + + def __getitem__(self, item): + if item in self._codes: + return self._codes[item] + + raise KeyError("Item {item} not found".format(item=item)) + + +HapStatusCodes = _HapStatusCodes() +HapBleStatusCodes = _HapBleStatusCodes() diff --git a/homekit/protocol/tlv.py b/homekit/protocol/tlv.py new file mode 100644 index 00000000..9dfd4102 --- /dev/null +++ b/homekit/protocol/tlv.py @@ -0,0 +1,219 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 logging + +logger = logging.getLogger("homekit.protocol.tlv") + + +class TLV: + """ + as described in Appendix 12 (page 251) + """ + + # Steps + M1 = bytearray(b"\x01") + M2 = bytearray(b"\x02") + M3 = bytearray(b"\x03") + M4 = bytearray(b"\x04") + M5 = bytearray(b"\x05") + M6 = bytearray(b"\x06") + + # Methods (see table 4-4 page 60) + PairSetup = bytearray(b"\x01") + PairVerify = bytearray(b"\x02") + AddPairing = bytearray(b"\x03") + RemovePairing = bytearray(b"\x04") + ListPairings = bytearray(b"\x05") + + # TLV Values (see table 4-6 page 61) + kTLVType_Method = 0 + kTLVType_Identifier = 1 + kTLVType_Salt = 2 + kTLVType_PublicKey = 3 + kTLVType_Proof = 4 + kTLVType_EncryptedData = 5 + kTLVType_State = 6 + kTLVType_Error = 7 + kTLVType_RetryDelay = 8 + kTLVType_Certificate = 9 + kTLVType_Signature = 10 + kTLVType_Permissions = 11 # 0x00 => reg. user, 0x01 => admin + kTLVType_Permission_RegularUser = bytearray(b"\x00") + kTLVType_Permission_AdminUser = bytearray(b"\x01") + kTLVType_FragmentData = 12 + kTLVType_FragmentLast = 13 + kTLVType_Separator = 255 + kTLVType_Separator_Pair = [255, bytearray(b"")] + kTLVType_SessionID = 0x0E # Table 6-27 page 116 + + # Errors (see table 4-5 page 60) + kTLVError_Unknown = bytearray(b"\x01") + kTLVError_Authentication = bytearray(b"\x02") + kTLVError_Backoff = bytearray(b"\x03") + kTLVError_MaxPeers = bytearray(b"\x04") + kTLVError_MaxTries = bytearray(b"\x05") + kTLVError_Unavailable = bytearray(b"\x06") + kTLVError_Busy = bytearray(b"\x07") + + # Table 6-27 page 116 + kTLVMethod_Resume = 0x07 + + # Additional Parameter Types for BLE (Table 6-9 page 98) + kTLVHAPParamValue = 0x01 + kTLVHAPParamAdditionalAuthorizationData = 0x02 + kTLVHAPParamOrigin = 0x03 + kTLVHAPParamCharacteristicType = 0x04 + kTLVHAPParamCharacteristicInstanceId = 0x05 + kTLVHAPParamServiceType = 0x06 + kTLVHAPParamServiceInstanceId = 0x07 + kTLVHAPParamTTL = 0x08 + kTLVHAPParamParamReturnResponse = 0x09 + kTLVHAPParamHAPCharacteristicPropertiesDescriptor = 0x0A + kTLVHAPParamGATTUserDescriptionDescriptor = 0x0B + kTLVHAPParamGATTPresentationFormatDescriptor = 0x0C + kTLVHAPParamGATTValidRange = 0x0D + kTLVHAPParamHAPStepValueDescriptor = 0x0E + kTLVHAPParamHAPServiceProperties = 0x0F + kTLVHAPParamHAPLinkedServices = 0x10 + kTLVHAPParamHAPValidValuesDescriptor = 0x11 + kTLVHAPParamHAPValidValuesRangeDescriptor = 0x12 + + @staticmethod + def decode_bytes(bs, expected=None) -> list: + return TLV.decode_bytearray(bytearray(bs), expected) + + @staticmethod + def decode_bytearray(ba: bytearray, expected=None) -> list: + result = [] + # do not influence caller! + tail = ba.copy() + while len(tail) > 0: + key = tail.pop(0) + if expected and key not in expected: + break + length = tail.pop(0) + value = tail[:length] + if length != len(value): + raise TlvParseException( + "Not enough data for length {} while decoding '{}'".format( + length, ba + ) + ) + tail = tail[length:] + + if len(result) > 0 and result[-1][0] == key: + result[-1][1] += value + else: + result.append([key, value]) + logger.debug("receiving %s", TLV.to_string(result)) + return result + + @staticmethod + def validate_key(k: int) -> bool: + try: + val = int(k) + if val < 0 or val > 255: + valid = False + else: + valid = True + except ValueError: + valid = False + return valid + + @staticmethod + def encode_list(d: list) -> bytearray: + logger.debug("sending %s", TLV.to_string(d)) + result = bytearray() + for p in d: + (key, value) = p + if not TLV.validate_key(key): + raise ValueError("Invalid key") + + # handle separators properly + if key == TLV.kTLVType_Separator: + if len(value) == 0: + result.append(key) + result.append(0) + else: + raise ValueError("Separator must not have data") + + while len(value) > 0: + result.append(key) + if len(value) > 255: + length = 255 + result.append(length) + for b in value[:length]: + result.append(b) + value = value[length:] + else: + length = len(value) + result.append(length) + for b in value[:length]: + result.append(b) + value = value[length:] + return result + + @staticmethod + def to_string(d) -> str: + def entry_to_string(entry_key, entry_value) -> str: + if isinstance(entry_value, bytearray): + return " {k}: ({len} bytes/{t}) 0x{v}\n".format( + k=entry_key, + v=entry_value.hex(), + len=len(entry_value), + t=type(entry_value), + ) + return " {k}: ({len} bytes/{t}) {v}\n".format( + k=entry_key, v=entry_value, len=len(entry_value), t=type(entry_value) + ) + + if isinstance(d, dict): + res = "{\n" + for k in d.keys(): + res += entry_to_string(k, d[k]) + res += "}\n" + else: + res = "[\n" + for k in d: + res += entry_to_string(k[0], k[1]) + res += "]\n" + return res + + @staticmethod + def reorder(tlv_array, preferred_order): + """ + This function is used to reorder the key value pairs of a TLV list according to a preferred order. If key from + the preferred_order list is not found, it is ignored. If a pair's key is not in the preferred order list it is + ignored as well. + + It is mostly used, if some accessory does not respect the order mentioned in the specification. + + :param tlv_array: a list of tupels containing key and value of the TLV + :param preferred_order: a list of keys describing how the key value pairs should be sorted. + :return: a TLV list containing only pairs whose key was in the preferred order list sorted by that order. + """ + tmp = [] + for key in preferred_order: + for item in tlv_array: + if item[0] == key: + tmp.append(item) + return tmp + + +class TlvParseException(Exception): + """Raised upon parse error with some TLV""" + + pass diff --git a/homekit/zerocnf/__init__.py b/homekit/zerocnf/__init__.py new file mode 100644 index 00000000..a6d275e3 --- /dev/null +++ b/homekit/zerocnf/__init__.py @@ -0,0 +1,215 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 time import sleep +import logging +from zeroconf import Zeroconf, ServiceBrowser +from _socket import inet_ntoa + + +from homekit.model import Categories +from homekit.model.feature_flags import FeatureFlags +from homekit.model.status_flags import IpStatusFlags + + +class CollectingListener(object): + """ + Helper class to collect all zeroconf announcements. + """ + + def __init__(self): + self.data = [] + + def remove_service(self, zeroconf, zeroconf_type, name): + # this is ignored since not interested in disappearing stuff + pass + + def add_service(self, zeroconf, zeroconf_type, name): + info = zeroconf.get_service_info(zeroconf_type, name) + if info is not None: + self.data.append(info) + + def get_data(self): + """ + Use this method to get the data of the collected announcements. + + :return: a List of zeroconf.ServiceInfo instances + """ + return self.data + + +def get_from_properties(props, key, default=None, case_sensitive=True): + """ + This function looks up the key in the given zeroconf service information properties. Those are a dict between bytes. + The key to lookup is therefore also of type bytes. + :param props: a dict from bytes to bytes. + :param key: bytes as key + :param default: the value to return, if the key was not found. Will be converted to str. + :param case_sensitive: If this is False, try to lookup keys also when they only match ignoring their case + :return: the value out of the dict as string (after decoding), the given default if the key was not not found but + the default was given or None + """ + if case_sensitive: + tmp_props = props + tmp_key = key + else: + tmp_props = {k.lower(): props[k] for k in props} + tmp_key = key.lower() + + if tmp_key in tmp_props: + return tmp_props[tmp_key] + else: + if default: + return str(default) + return None + + +def discover_homekit_devices(max_seconds=10): + """ + This method discovers all HomeKit Accessories. It browses for devices in the _hap._tcp.local. domain and checks if + all required fields are set in the text record. It one field is missing, it will be excluded from the result list. + + :param max_seconds: the number of seconds we will wait for the devices to be discovered + :return: a list of dicts containing all fields as described in table 5.7 page 69 + """ + zeroconf = Zeroconf() + listener = CollectingListener() + ServiceBrowser(zeroconf, "_hap._tcp.local.", listener) + sleep(max_seconds) + tmp = [] + for info in listener.get_data(): + # from Bonjour discovery + data = { + "name": info.name, + "address": inet_ntoa(info.addresses[0]), + "port": info.port, + } + + logging.debug("candidate data %s", info.properties) + + data.update( + parse_discovery_properties(decode_discovery_properties(info.properties)) + ) + + if "c#" not in data or "md" not in data: + continue + logging.debug("found Homekit IP accessory %s", data) + tmp.append(data) + + zeroconf.close() + return tmp + + +def decode_discovery_properties(props): + """ + This method decodes unicode bytes in _hap._tcp Bonjour TXT record keys to python strings. + + :params: a dictionary of key/value TXT records from Bonjour discovery. These are assumed + to be bytes type. + :return: A dictionary of key/value TXT records from Bonjour discovery. These are now str. + """ + out = {} + for k, value in props.items(): + out[k.decode("utf-8")] = value.decode("utf-8") + return out + + +def parse_discovery_properties(props): + """ + This method normalizes and parses _hap._tcp Bonjour TXT record keys. + + This is done automatically if you are using the discovery features built in to the library. If you are + integrating into an existing system it may already do its own Bonjour discovery. In that case you can + call this function to normalize the properties it has discovered. + + :param props: a dictionary of key/value TXT records from doing Bonjour discovery. These should be + decoded as strings already. Byte data should be decoded with decode_discovery_properties. + :return: A dictionary contained the parsed and normalized data. + """ + data = {} + + # stuff taken from the Bonjour TXT record (see table 5-7 on page 69) + conf_number = get_from_properties(props, "c#", case_sensitive=False) + if conf_number: + data["c#"] = conf_number + + feature_flags = get_from_properties(props, "ff", case_sensitive=False) + if feature_flags: + flags = int(feature_flags) + else: + flags = 0 + data["ff"] = flags + data["flags"] = FeatureFlags[flags] + + dev_id = get_from_properties(props, "id", case_sensitive=False) + if dev_id: + data["id"] = dev_id + + model_name = get_from_properties(props, "md", case_sensitive=False) + if model_name: + data["md"] = model_name + + protocol_version = get_from_properties( + props, "pv", case_sensitive=False, default="1.0" + ) + if protocol_version: + data["pv"] = protocol_version + + status = get_from_properties(props, "s#", case_sensitive=False) + if status: + data["s#"] = status + + status_flag = get_from_properties(props, "sf", case_sensitive=False) + if status_flag: + data["sf"] = status_flag + data["statusflags"] = IpStatusFlags[int(status_flag)] + + category_id = get_from_properties(props, "ci", case_sensitive=False) + if category_id: + category = props["ci"] + data["ci"] = category + data["category"] = Categories[int(category)] + + return data + + +def find_device_ip_and_port(device_id: str, max_seconds=10): + """ + Try to find a HomeKit Accessory via Bonjour. The process is time boxed by the second parameter which sets an upper + limit of `max_seconds` before it times out. The runtime of the function may be longer because of the Bonjour + handling code. + + :param device_id: the Accessory's pairing id + :param max_seconds: the number of seconds to wait for the accessory to be found + :return: a dict with ip and port if the accessory was found or None + """ + result = None + zeroconf = Zeroconf() + listener = CollectingListener() + ServiceBrowser(zeroconf, "_hap._tcp.local.", listener) + counter = 0 + + while result is None and counter < max_seconds: + sleep(1) + data = listener.get_data() + for info in data: + if info.properties[b"id"].decode() == device_id: + result = {"ip": inet_ntoa(info.addresses[0]), "port": info.port} + break + counter += 1 + + zeroconf.close() + return result diff --git a/pylintrc b/pylintrc new file mode 100644 index 00000000..94ddd8b8 --- /dev/null +++ b/pylintrc @@ -0,0 +1,66 @@ +[MASTER] +ignore=tests +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs=2 +persistent=no + +[BASIC] +good-names=id,i,j,k,ex,Run,_,fp + +[MESSAGES CONTROL] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# global-statement - used for the on-demand requirement installation +# redefined-variable-type - this is Python, we're duck typing! +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# unnecessary-pass - readability for functions which only contain pass +# import-outside-toplevel - TODO +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + global-statement, + import-outside-toplevel, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + redefined-variable-type, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unnecessary-pass, + unused-argument, + wrong-import-order +enable= + use-symbolic-message-instead + +[REPORTS] +score=no + +[TYPECHECK] +# For attrs +ignored-classes=_CountingAttr + +[FORMAT] +expected-line-ending-format=LF diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..1ef318bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +target-version = ["py37", "py38"] + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ca55d323 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +zeroconf +hkdf +ed25519 +cryptography>=2.5 +coverage +flake8 +pytest +pytest-asyncio diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..6fe18f09 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,64 @@ +[metadata] +license = Apache License 2.0 +license_file = LICENSE.md +platforms = any +description = Open-source HomeKit client for Python 3. +long_description = file: README.md +keywords = home, automation +classifier = + License :: OSI Approved :: Apache Software License + Topic :: Home Automation + Intended Audience :: Developers + Intended Audience :: End Users/Desktop + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + +[tool:pytest] +testpaths = tests +norecursedirs = .git testing_config + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = homeassistant,tests +forced_separate = tests +combine_as_imports = true + +[mypy] +python_version = 3.7 +ignore_errors = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..cb75bede --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 setuptools + + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setuptools.setup( + name="aiohomekit", + packages=setuptools.find_packages(exclude=["tests"]), + version="0.0.2", + description="asyncio library for HomeKit accessories", + author="John Carr", + author_email="pypi@unrouted.co.uk", + url="https://github.com/Jc2k/aiohomekit", + keywords=["HomeKit"], + install_requires=["hkdf", "ed25519", "cryptography>=2.5",], + extras_require={"IP": ["zeroconf"], "BLE": ["aioble"]}, + license="Apache License 2.0", + long_description=long_description, + long_description_content_type="text/markdown", +) diff --git a/tests/aio/conftest.py b/tests/aio/conftest.py new file mode 100644 index 00000000..4828b295 --- /dev/null +++ b/tests/aio/conftest.py @@ -0,0 +1,190 @@ +import errno +import socket +import tempfile +import threading +import time +from unittest import mock + +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.controller import Controller +from homekit.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) + + 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() + + 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) + + 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() + + 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..0e09569f --- /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..e80e1473 --- /dev/null +++ b/tests/aio/test_ip_discovery.py @@ -0,0 +1,43 @@ +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..de226cb9 --- /dev/null +++ b/tests/aio/test_ip_pairing.py @@ -0,0 +1,223 @@ +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 diff --git a/tests/aio/test_main.py b/tests/aio/test_main.py new file mode 100644 index 00000000..c6d3958b --- /dev/null +++ b/tests/aio/test_main.py @@ -0,0 +1,78 @@ +"""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" + ) diff --git a/tests/bleCharacteristicUnits_test.py b/tests/bleCharacteristicUnits_test.py new file mode 100644 index 00000000..61cf778a --- /dev/null +++ b/tests/bleCharacteristicUnits_test.py @@ -0,0 +1,27 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.model.characteristics.characteristic_units import BleCharacteristicUnits + + +class BleCharacteristicUnitsTest(unittest.TestCase): + def test_get_unknown_key(self): + self.assertEqual("unknown", BleCharacteristicUnits.get(-0xC0FFEE, "unknown")) + + def test_get_known_key(self): + self.assertEqual("celsius", BleCharacteristicUnits.get(0x272F, "unknown")) diff --git a/tests/chacha20poly1305_test.py b/tests/chacha20poly1305_test.py new file mode 100644 index 00000000..e64db6f3 --- /dev/null +++ b/tests/chacha20poly1305_test.py @@ -0,0 +1,523 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.crypto.chacha20poly1305 import ( + pad16, + chacha20_quarter_round, + chacha20_create_initial_state, + chacha20_aead_decrypt, + chacha20_aead_verify_tag, + chacha20_aead_encrypt, + chacha20_block, + chacha20_encrypt, + calc_s, + calc_r, + clamp, + poly1305_key_gen, + poly1305_mac, +) + + +class TestChacha20poly1305(unittest.TestCase): + def test_pad16_does_not_pad_multiples_of_16(self): + input_data = b"1234567890ABCDEF" + pad = pad16(input_data) + self.assertEqual(pad, bytearray(b"")) + + def test_example2_1_1(self): + # Test aus 2.1.1 + s = [ + 0x11111111, + 0, + 0, + 0, + 0x01020304, + 0, + 0, + 0, + 0x9B8D6F43, + 0, + 0, + 0, + 0x01234567, + 0, + 0, + 0, + ] + chacha20_quarter_round(s, 0, 4, 8, 12) + self.assertEqual(s[0], 0xEA2A92F4) + self.assertEqual(s[4], 0xCB1CF8CE) + self.assertEqual(s[8], 0x4581472E) + self.assertEqual(s[12], 0x5881C4BB) + + def test_example2_2_1(self): + # Test aus 2.2.1 + s = [ + 0x879531E0, + 0xC5ECF37D, + 0x516461B1, + 0xC9A62F8A, + 0x44C20EF3, + 0x3390AF7F, + 0xD9FC690B, + 0x2A5F714C, + 0x53372767, + 0xB00A5631, + 0x974C541A, + 0x359E9963, + 0x5C971061, + 0x3D631689, + 0x2098D9D6, + 0x91DBD320, + ] + chacha20_quarter_round(s, 2, 7, 8, 13) + + self.assertEqual(s[2], 0xBDB886DC) + self.assertEqual(s[7], 0xCFACAFD2) + self.assertEqual(s[8], 0xE46BEA80) + self.assertEqual(s[13], 0xCCC07C79) + + def test_example2_3_2(self): + # Test aus 2.3.2 + k = 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F .to_bytes( + length=32, byteorder="big" + ) + n = 0x000000090000004A00000000 .to_bytes(length=12, byteorder="big") + c = 1 + init = chacha20_create_initial_state(k, n, c) + self.assertEqual( + init, + [ + 0x61707865, + 0x3320646E, + 0x79622D32, + 0x6B206574, + 0x03020100, + 0x07060504, + 0x0B0A0908, + 0x0F0E0D0C, + 0x13121110, + 0x17161514, + 0x1B1A1918, + 0x1F1E1D1C, + 0x00000001, + 0x09000000, + 0x4A000000, + 0x00000000, + ], + ) + r = chacha20_block(k, n, c) + p = int( + "".join( + """ + 10f1e7e4 d13b5915 500fdd1f a32071c4 c7d1f4c7 + 33c06803 0422aa9a c3d46c4e d2826446 079faa09 + 14c2d705 d98b02a2 b5129cd1 de164eb9 cbd083e8 + a2503c4e + """.split() + ), + 16, + ) + self.assertEqual(r, p) + + def test_example2_4_2(self): + # Test aus 2.4.2 + k = 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F .to_bytes( + length=32, byteorder="big" + ) + n = 0x000000000000004A00000000 .to_bytes(length=12, byteorder="big") + c = 1 + plain_text = ( + "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, " + "sunscreen would be it." + ) + r = chacha20_encrypt(k, c, n, plain_text.encode()) + r_ = [ + 0x6E, + 0x2E, + 0x35, + 0x9A, + 0x25, + 0x68, + 0xF9, + 0x80, + 0x41, + 0xBA, + 0x07, + 0x28, + 0xDD, + 0x0D, + 0x69, + 0x81, + 0xE9, + 0x7E, + 0x7A, + 0xEC, + 0x1D, + 0x43, + 0x60, + 0xC2, + 0x0A, + 0x27, + 0xAF, + 0xCC, + 0xFD, + 0x9F, + 0xAE, + 0x0B, + 0xF9, + 0x1B, + 0x65, + 0xC5, + 0x52, + 0x47, + 0x33, + 0xAB, + 0x8F, + 0x59, + 0x3D, + 0xAB, + 0xCD, + 0x62, + 0xB3, + 0x57, + 0x16, + 0x39, + 0xD6, + 0x24, + 0xE6, + 0x51, + 0x52, + 0xAB, + 0x8F, + 0x53, + 0x0C, + 0x35, + 0x9F, + 0x08, + 0x61, + 0xD8, + 0x07, + 0xCA, + 0x0D, + 0xBF, + 0x50, + 0x0D, + 0x6A, + 0x61, + 0x56, + 0xA3, + 0x8E, + 0x08, + 0x8A, + 0x22, + 0xB6, + 0x5E, + 0x52, + 0xBC, + 0x51, + 0x4D, + 0x16, + 0xCC, + 0xF8, + 0x06, + 0x81, + 0x8C, + 0xE9, + 0x1A, + 0xB7, + 0x79, + 0x37, + 0x36, + 0x5A, + 0xF9, + 0x0B, + 0xBF, + 0x74, + 0xA3, + 0x5B, + 0xE6, + 0xB4, + 0x0B, + 0x8E, + 0xED, + 0xF2, + 0x78, + 0x5E, + 0x42, + 0x87, + 0x4D, + ] + r_ = bytearray(r_) + self.assertEqual(r, r_) + + def test_example2_5_2(self): + # Test aus 2.5.2 + key = 0x85D6BE7857556D337F4452FE42D506A80103808AFB0DB2FD4ABFF6AF4149F51B .to_bytes( + length=32, byteorder="big" + ) + text = "Cryptographic Forum Research Group" + + s = calc_s(key) + self.assertEqual(s, 0x1BF54941AFF6BF4AFDB20DFB8A800301) + + r = calc_r(key) + self.assertEqual(r, 0x85D6BE7857556D337F4452FE42D506A8) + + r = clamp(r) + self.assertEqual(r, 0x806D5400E52447C036D555408BED685, "clamping") + + r = poly1305_mac(text.encode(), key) + r_ = [ + 0xA8, + 0x06, + 0x1D, + 0xC1, + 0x30, + 0x51, + 0x36, + 0xC6, + 0xC2, + 0x2B, + 0x8B, + 0xAF, + 0x0C, + 0x01, + 0x27, + 0xA9, + ] + r_ = bytearray(r_) + self.assertEqual(r, r_) + + def test_example2_6_2(self): + # Test aus 2.6.2 + key = 0x808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9F .to_bytes( + length=32, byteorder="big" + ) + nonce = 0x000000000001020304050607 .to_bytes(length=12, byteorder="big") + r_ = [ + 0x8A, + 0xD5, + 0xA0, + 0x8B, + 0x90, + 0x5F, + 0x81, + 0xCC, + 0x81, + 0x50, + 0x40, + 0x27, + 0x4A, + 0xB2, + 0x94, + 0x71, + 0xA8, + 0x33, + 0xB6, + 0x37, + 0xE3, + 0xFD, + 0x0D, + 0xA5, + 0x08, + 0xDB, + 0xB8, + 0xE2, + 0xFD, + 0xD1, + 0xA6, + 0x46, + ] + r_ = bytes(r_) + + r = poly1305_key_gen(key, nonce) + self.assertEqual(r, r_) + + def test_example2_8_2(self): + # Test aus 2.8.2 + plain_text = ( + "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, " + "sunscreen would be it.".encode() + ) + aad = 0x50515253C0C1C2C3C4C5C6C7 .to_bytes(length=12, byteorder="big") + key = 0x808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9F .to_bytes( + length=32, byteorder="big" + ) + iv = 0x4041424344454647 .to_bytes(length=8, byteorder="big") + fixed = 0x07000000 .to_bytes(length=4, byteorder="big") + r_ = ( + bytes( + [ + 0xD3, + 0x1A, + 0x8D, + 0x34, + 0x64, + 0x8E, + 0x60, + 0xDB, + 0x7B, + 0x86, + 0xAF, + 0xBC, + 0x53, + 0xEF, + 0x7E, + 0xC2, + 0xA4, + 0xAD, + 0xED, + 0x51, + 0x29, + 0x6E, + 0x08, + 0xFE, + 0xA9, + 0xE2, + 0xB5, + 0xA7, + 0x36, + 0xEE, + 0x62, + 0xD6, + 0x3D, + 0xBE, + 0xA4, + 0x5E, + 0x8C, + 0xA9, + 0x67, + 0x12, + 0x82, + 0xFA, + 0xFB, + 0x69, + 0xDA, + 0x92, + 0x72, + 0x8B, + 0x1A, + 0x71, + 0xDE, + 0x0A, + 0x9E, + 0x06, + 0x0B, + 0x29, + 0x05, + 0xD6, + 0xA5, + 0xB6, + 0x7E, + 0xCD, + 0x3B, + 0x36, + 0x92, + 0xDD, + 0xBD, + 0x7F, + 0x2D, + 0x77, + 0x8B, + 0x8C, + 0x98, + 0x03, + 0xAE, + 0xE3, + 0x28, + 0x09, + 0x1B, + 0x58, + 0xFA, + 0xB3, + 0x24, + 0xE4, + 0xFA, + 0xD6, + 0x75, + 0x94, + 0x55, + 0x85, + 0x80, + 0x8B, + 0x48, + 0x31, + 0xD7, + 0xBC, + 0x3F, + 0xF4, + 0xDE, + 0xF0, + 0x8E, + 0x4B, + 0x7A, + 0x9D, + 0xE5, + 0x76, + 0xD2, + 0x65, + 0x86, + 0xCE, + 0xC6, + 0x4B, + 0x61, + 0x16, + ] + ), + bytes( + [ + 0x1A, + 0xE1, + 0x0B, + 0x59, + 0x4F, + 0x09, + 0xE2, + 0x6A, + 0x7E, + 0x90, + 0x2E, + 0xCB, + 0xD0, + 0x60, + 0x06, + 0x91, + ] + ), + ) + + r = chacha20_aead_encrypt(aad, key, iv, fixed, plain_text) + self.assertEqual(r[0], r_[0], "ciphertext") + self.assertEqual(r[1], r_[1], "tag") + + self.assertTrue(chacha20_aead_verify_tag(aad, key, iv, fixed, r[0] + r[1])) + self.assertFalse( + chacha20_aead_verify_tag( + aad, key, iv, fixed, r[0] + r[1] + bytes([0, 1, 2, 3]) + ) + ) + + plain_text_ = chacha20_aead_decrypt(aad, key, iv, fixed, r[0] + r[1]) + self.assertEqual(plain_text, plain_text_) + + self.assertFalse( + chacha20_aead_decrypt( + aad, key, iv, fixed, r[0] + r[1] + bytes([0, 1, 2, 3]) + ) + ) diff --git a/tests/characteristicTypes_test.py b/tests/characteristicTypes_test.py new file mode 100644 index 00000000..230ec4f3 --- /dev/null +++ b/tests/characteristicTypes_test.py @@ -0,0 +1,94 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.model.characteristics.characteristic_types import CharacteristicsTypes + + +class CharacteristicTypesTest(unittest.TestCase): + def test_get_uuid_full_uuid(self): + self.assertEqual( + "0000006D-0000-1000-8000-0026BB765291", + CharacteristicsTypes.get_uuid("0000006D-0000-1000-8000-0026BB765291"), + ) + + def test_get_uuid_short_uuid(self): + self.assertEqual( + "0000006D-0000-1000-8000-0026BB765291", CharacteristicsTypes.get_uuid("6D") + ) + + def test_get_uuid_name(self): + self.assertEqual( + "0000006D-0000-1000-8000-0026BB765291", + CharacteristicsTypes.get_uuid("public.hap.characteristic.position.current"), + ) + + def test_get_uuid_unknown(self): + self.assertRaises(KeyError, CharacteristicsTypes.get_uuid, "UNKNOWN") + + def test_get_short_uuid_full_uuid(self): + self.assertEqual( + "6D", + CharacteristicsTypes.get_short_uuid("0000006D-0000-1000-8000-0026BB765291"), + ) + + def test_get_short_uuid_name(self): + self.assertEqual( + "6D", + CharacteristicsTypes.get_short_uuid( + "public.hap.characteristic.position.current" + ), + ) + + def test_get_short_uuid_short(self): + self.assertEqual("6D", CharacteristicsTypes.get_short_uuid("6D")) + + def test_get_short_uuid_unknown(self): + self.assertRaises(KeyError, CharacteristicsTypes.get_short_uuid, "UNKNOWN") + + def test_get_short_uuid_passthrough(self): + self.assertEqual( + "0000006D-1234-1234-1234-012345678901", + CharacteristicsTypes.get_short_uuid("0000006D-1234-1234-1234-012345678901"), + ) + + def test_get_short_full_uuid(self): + self.assertEqual( + "position.current", + CharacteristicsTypes.get_short("0000006D-0000-1000-8000-0026BB765291"), + ) + + def test_get_short_short_uuid(self): + self.assertEqual("position.current", CharacteristicsTypes.get_short("6D")) + + def test_get_short_unknown(self): + self.assertEqual( + "Unknown Characteristic 1234", CharacteristicsTypes.get_short("1234") + ) + + def test_getitem_short_uuid(self): + self.assertEqual( + "public.hap.characteristic.position.current", CharacteristicsTypes["6D"] + ) + + def test_getitem_name(self): + self.assertEqual( + "6D", CharacteristicsTypes["public.hap.characteristic.position.current"] + ) + + def test_getitem_unknown(self): + self.assertRaises(KeyError, CharacteristicsTypes.__getitem__, "UNKNOWN") diff --git a/tests/characteristicsTypes_test.py b/tests/characteristicsTypes_test.py new file mode 100644 index 00000000..5bc0d300 --- /dev/null +++ b/tests/characteristicsTypes_test.py @@ -0,0 +1,74 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.model.characteristics import CharacteristicsTypes + + +class TestCharacteristicsTypes(unittest.TestCase): + def test_getitem_forward(self): + self.assertEqual( + CharacteristicsTypes[CharacteristicsTypes.ON], + "public.hap.characteristic.on", + ) + + def test_getitem_reverse(self): + self.assertEqual( + CharacteristicsTypes["public.hap.characteristic.on"], + CharacteristicsTypes.ON, + ) + + def test_getitem_unknown(self): + # self.assertEqual(CharacteristicsTypes[-99], 'Unknown Characteristic -99?') + self.assertRaises(KeyError, CharacteristicsTypes.__getitem__, 99) + + def test_get_uuid_forward(self): + self.assertEqual( + CharacteristicsTypes.get_uuid(CharacteristicsTypes.ON), + "00000025-0000-1000-8000-0026BB765291", + ) + + def test_get_uuid_reverse(self): + self.assertEqual( + CharacteristicsTypes.get_uuid("public.hap.characteristic.on"), + "00000025-0000-1000-8000-0026BB765291", + ) + + def test_get_uuid_unknown(self): + self.assertRaises(KeyError, CharacteristicsTypes.get_uuid, "XXX") + + def test_get_short(self): + self.assertEqual(CharacteristicsTypes.get_short(CharacteristicsTypes.ON), "on") + self.assertEqual( + CharacteristicsTypes.get_short( + CharacteristicsTypes.get_uuid(CharacteristicsTypes.ON) + ), + "on", + ) + self.assertEqual( + CharacteristicsTypes.get_short(CharacteristicsTypes.DOOR_STATE_TARGET), + "door-state.target", + ) + self.assertEqual( + CharacteristicsTypes.get_short( + CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT + ), + "air-purifier.state.current", + ) + self.assertEqual( + CharacteristicsTypes.get_short("1a"), "lock-management.auto-secure-timeout" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7d0b5213 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import logging + +import pytest + + +@pytest.fixture(autouse=True) +def configure_test_logging(caplog): + caplog.set_level(logging.DEBUG) diff --git a/tests/feature_flags_test.py b/tests/feature_flags_test.py new file mode 100644 index 00000000..77d7db80 --- /dev/null +++ b/tests/feature_flags_test.py @@ -0,0 +1,35 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.model.feature_flags import FeatureFlags + + +class TestFeatureFlags(unittest.TestCase): + def test_no_support_hap_pairing(self): + self.assertEqual(FeatureFlags[0], "No support for HAP Pairing") + + def test_support_hap_pairing(self): + self.assertEqual(FeatureFlags[1], "Supports HAP Pairing") + + def test_bug_143(self): + # 0b10 -> 2 means no hap pairing support? + self.assertEqual(FeatureFlags[2], "No support for HAP Pairing") + + +# def test_unknown_code(self): +# self.assertRaises(KeyError, FeatureFlags.__getitem__, 99) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..e515494e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,22 @@ +# +# Copyright 2019 aiohomekit team +# +# 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. +# + + +def pin_returner(pin): + def tmp(): + return pin + + return tmp diff --git a/tests/httpStatusCodes_test.py b/tests/httpStatusCodes_test.py new file mode 100644 index 00000000..65fbfc86 --- /dev/null +++ b/tests/httpStatusCodes_test.py @@ -0,0 +1,30 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.http_impl import HttpStatusCodes + + +class TestHttpStatusCodes(unittest.TestCase): + def test_1(self): + self.assertEqual( + HttpStatusCodes[HttpStatusCodes.INTERNAL_SERVER_ERROR], + "Internal Server Error", + ) + + def test_unknown_code(self): + self.assertRaises(KeyError, HttpStatusCodes.__getitem__, 99) diff --git a/tests/http_response_test.py b/tests/http_response_test.py new file mode 100644 index 00000000..a50c222e --- /dev/null +++ b/tests/http_response_test.py @@ -0,0 +1,194 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest +import json + +from homekit.http_impl.response import HttpResponse + + +class TestHttpResponse(unittest.TestCase): + def parse(data): + response = HttpResponse() + for i in range(0, len(data)): + response.parse(data[i]) + if response.is_read_completely(): + break + return response + + def test_example1(self): + parts = [ + bytearray( + b"HTTP/1.1 200 OK\r\nContent-Type: application/hap+json\r\n" + b"Transfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n" + ), + bytearray( + b'5f\r\n{"characteristics":[{"aid":1,"iid":10,"value":35},' + b'{"aid":1,"iid":13,"value":36.0999984741211}]}\r\n' + ), + bytearray(b"0\r\n\r\n"), + ] + res = TestHttpResponse.parse(parts) + self.assertEqual(res.code, 200) + self.assertEqual(res.get_http_name(), "HTTP") + self.assertEqual( + res.body, + b'{"characteristics":[{"aid":1,"iid":10,"value":35},{"aid":1,"iid":13,"value":36.0999984741211}]}', + ) + json.loads(res.body.decode()) + + def test_example2(self): + parts = [ + bytearray( + b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nTransfer-Encoding: chunked\r\n\r\n" + ), + bytearray( + b'5f\r\n{"characteristics":[{"aid":1,"iid":10,"value":35},' + b'{"aid":1,"iid":13,"value":33.2000007629395}]}\r\n' + ), + bytearray(b"0\r\n\r\n"), + ] + res = TestHttpResponse.parse(parts) + self.assertEqual(res.code, 200) + self.assertEqual(res.get_http_name(), "EVENT") + self.assertEqual( + res.body, + b'{"characteristics":[{"aid":1,"iid":10,"value":35},{"aid":1,"iid":13,"value":33.2000007629395}]}', + ) + j = json.loads(res.body.decode()) + self.assertEqual(j["characteristics"][1]["value"], 33.2000007629395) + + def test_example3(self): + parts = [ + bytearray( + b"HTTP/1.1 200 OK\r\nServer: BaseHTTP/0.6 Python/3.5.3\r\nDate: Mon, 04 Jun 2018 20:06:06 GMT\r\n" + b'Content-Type: application/hap+json\r\nContent-Length: 3740\r\n\r\n{"accessories": [{"services": ' + b'[{"characteristics": [{"maxLen": 64, "type": "00000014-0000-1000-8000-0026BB765291", "format": "bool", ' + b'"description": "Identify", "perms": ["pw"], "maxDataLen": 2097152, "iid": 3}, {"maxLen": 64, "type": ' + b'"00000020-0000-1000-8000-0026BB765291", "format": "string", "description": "Manufacturer", "perms": ' + b'["pr"], "maxDataLen": 2097152, "value": "lusiardi.de", "iid": 4}, {"maxLen": 64, "type": ' + b'"00000021-0000-1000-8000-0026BB765291", "format": "string", "description": "Model", "perms": ["pr"], ' + b'"maxDataLen": 2097152, "value": "Demoserver", "iid": 5}, {"maxLen": 64, "type": ' + b'"00000023-0000-1000-8000-0026BB765291", "format": "string", "description": "Name", "perms": ["pr"], ' + b'"maxDataLen": 2097152, "value": "Notifier", "iid": 6}, {"maxLen": 64, "type": ' + b'"00000030-0000-1000-8000-0026BB765291", "format": "string", "description": "Serial Number", "' + ), + bytearray( + b'perms": ["pr"], "maxDataLen": 2097152, "value": "0001", "iid": 7}, {"maxLen": 64, "type": ' + b'"00000052-0000-1000-8000-0026BB765291", "format": "string", "description": "Firmware Revision",' + b' "perms": ["pr"], "maxDataLen": 2097152, "value": "0.1", "iid": 8}], "type": ' + b'"0000003E-0000-1000-8000-0026BB765291", "iid": 2}, {"characteristics": [{"maxLen": 64, "type": ' + b'"00000025-0000-1000-8000-0026BB765291", "format": "bool", "description": ' + b'"Switch state (on/off)", "perms": ["pw", "pr", "ev"], "maxDataLen": 2097152, "value": false, ' + b'"iid": 10}, {"maxDataLen": 2097152, "minStep": 1, "description": "Brightness in percent", ' + b'"unit": "percentage", "minValue": 0, "perms": ["pr", "pw", "ev"], "maxValue": 100, "maxLen": ' + b'64, "type": "00000008-0000-1000-8000-0026BB765291", "format": "int", "value": 0, "iid": 11}, ' + b'{"maxDataLen": 2097152, "minStep": 1, "description": "Hue in arc degrees", "unit": ' + b'"arcdegrees", "minValue": 0, "perms": ["pr", "pw", "ev"], "maxValue": 360, "maxLen": 64, ' + b'"type": "00000013-0000-1000-8000-0026BB765291", "form' + ), + bytearray( + b'at": "float", "value": 0, "iid": 12}, {"maxDataLen": 2097152, "minStep": 1, "description": ' + b'"Saturation in percent", "unit": "percentage", "minValue": 0, "perms": ["pr", "pw", "ev"], ' + b'"maxValue": 100, "maxLen": 64, "type": "0000002F-0000-1000-8000-0026BB765291", "format": ' + b'"float", "value": 0, "iid": 13}], "type": "00000043-0000-1000-8000-0026BB765291", "iid": 9}], ' + b'"aid": 1}, {"services": [{"characteristics": [{"maxLen": 64, "type": ' + b'"00000014-0000-1000-8000-0026BB765291", "format": "bool", "description": "Identify", "perms": ' + b'["pw"], "maxDataLen": 2097152, "iid": 16}, {"maxLen": 64, "type": ' + b'"00000020-0000-1000-8000-0026BB765291", "format": "string", "description": "Manufacturer", ' + b'"perms": ["pr"], "maxDataLen": 2097152, "value": "lusiardi.de", "iid": 17}, {"maxLen": 64, ' + b'"type": "00000021-0000-1000-8000-0026BB765291", "format": "string", "description": "Model", ' + b'"perms": ["pr"], "maxDataLen": 2097152, "value": "Demoserver", "iid": 18}, {"maxLen": 64, ' + b'"type": "00000023-0000-1000-8000-0026BB765291", "format": "string"' + ), + bytearray( + b', "description": "Name", "perms": ["pr"], "maxDataLen": 2097152, "value": "Dummy", "iid": 19},' + b' {"maxLen": 64, "type": "00000030-0000-1000-8000-0026BB765291", "format": "string", ' + b'"description": "Serial Number", "perms": ["pr"], "maxDataLen": 2097152, "value": "0001", ' + b'"iid": 20}, {"maxLen": 64, "type": "00000052-0000-1000-8000-0026BB765291", "format": "string",' + b' "description": "Firmware Revision", "perms": ["pr"], "maxDataLen": 2097152, "value": "0.1", ' + b'"iid": 21}], "type": "0000003E-0000-1000-8000-0026BB765291", "iid": 15}, {"characteristics": ' + b'[{"perms": ["pw", "pr"], "maxLen": 64, "minValue": 2, "type": ' + b'"00000023-0000-1000-8000-0026BB765291", "format": "float", "description": "Test", "minStep":' + b' 0.25, "maxDataLen": 2097152, "iid": 23}], "type": "00000040-0000-1000-8000-0026BB765291", ' + b'"iid": 22}], "aid": 14}]}' + ), + ] + res = TestHttpResponse.parse(parts) + self.assertEqual(res.code, 200) + self.assertEqual(res.get_http_name(), "HTTP") + json.loads(res.body.decode()) + + def test_example4(self): + parts = [ + bytearray( + b"HTTP/1.1 200 OK\r\nServer: BaseHTTP/0.6 Python/3.5.3\r\nDate: Mon, 04 Jun 2018 21:38:07 " + b'GMT\r\nContent-Type: application/hap+json\r\nContent-Length: 3740\r\n\r\n{"accessories": ' + b'[{"aid": 1, "services": [{"type": "0000003E-0000-1000-8000-0026BB765291", ' + b'"characteristics": [{"format": "bool", "maxLen": 64, "iid": 3, "description": "Identify", ' + b'"perms": ["pw"], "type": "00000014-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, ' + b'{"value": "lusiardi.de", "format": "string", "maxLen": 64, "iid": 4, "description": ' + b'"Manufacturer", "perms": ["pr"], "type": "00000020-0000-1000-8000-0026BB765291", ' + b'"maxDataLen": 2097152}, {"value": "Demoserver", "format": "string", "maxLen": 64, ' + b'"iid": 5, "description": "Model", "perms": ["pr"], "type": ' + b'"00000021-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "Notifier", ' + b'"format": "string", "maxLen": 64, "iid": 6, "description": "Name", "perms": ["pr"], ' + b'"type": "00000023-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "0001", ' + b'"format": "string", "maxLen": 64, "iid":' + ), + bytearray( + b' 7, "description": "Serial Number", "perms": ["pr"], "type": ' + b'"00000030-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "0.1", "format": ' + b'"string", "maxLen": 64, "iid": 8, "description": "Firmware Revision", "perms": ["pr"], "type": ' + b'"00000052-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}], "iid": 2}, {"type": ' + b'"00000043-0000-1000-8000-0026BB765291", "characteristics": [{"value": false, "format": "bool", ' + b'"maxLen": 64, "iid": 10, "description": "Switch state (on/off)", "perms": ["pw", "pr", "ev"], ' + b'"type": "00000025-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"maxValue": 100, ' + b'"format": "int", "minStep": 1, "description": "Brightness in percent", "perms": ["pr", "pw", ' + b'"ev"], "maxDataLen": 2097152, "type": "00000008-0000-1000-8000-0026BB765291", "maxLen": 64, ' + b'"iid": 11, "value": 0, "unit": "percentage", "minValue": 0}, {"maxValue": 360, "format": ' + b'"float", "minStep": 1, "description": "Hue in arc degrees", "perms": ["pr", "pw", "ev"], ' + b'"maxDataLen": 2097152, "type": "00000013-0000-1000' + ), + bytearray( + b'-8000-0026BB765291", "maxLen": 64, "iid": 12, "value": 0, "unit": "arcdegrees", "minValue": 0},' + b' {"maxValue": 100, "format": "float", "minStep": 1, "description": "Saturation in percent", ' + b'"perms": ["pr", "pw", "ev"], "maxDataLen": 2097152, "type": ' + b'"0000002F-0000-1000-8000-0026BB765291", "maxLen": 64, "iid": 13, "value": 0, "unit": ' + b'"percentage", "minValue": 0}], "iid": 9}]}, {"aid": 14, "services": [{"type": ' + b'"0000003E-0000-1000-8000-0026BB765291", "characteristics": [{"format": "bool", "maxLen": 64, ' + b'"iid": 16, "description": "Identify", "perms": ["pw"], "type": ' + b'"00000014-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "lusiardi.de", ' + b'"format": "string", "maxLen": 64, "iid": 17, "description": "Manufacturer", "perms": ["pr"], ' + b'"type": "00000020-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "Demoserver",' + b' "format": "string", "maxLen": 64, "iid": 18, "description": "Model", "perms": ["pr"], "type": ' + b'"00000021-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "Dummy", "fo' + ), + bytearray( + b'rmat": "string", "maxLen": 64, "iid": 19, "description": "Name", "perms": ["pr"], "type": ' + b'"00000023-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "0001", "format": ' + b'"string", "maxLen": 64, "iid": 20, "description": "Serial Number", "perms": ["pr"], "type": ' + b'"00000030-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}, {"value": "0.1", "format": ' + b'"string", "maxLen": 64, "iid": 21, "description": "Firmware Revision", "perms": ["pr"], ' + b'"type": "00000052-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}], "iid": 15}, {"type": ' + b'"00000040-0000-1000-8000-0026BB765291", "characteristics": [{"minStep": 0.25, "format": ' + b'"float", "maxLen": 64, "iid": 23, "description": "Test", "perms": ["pw", "pr"], "minValue": 2, ' + b'"type": "00000023-0000-1000-8000-0026BB765291", "maxDataLen": 2097152}], "iid": 22}]}]}' + ), + ] + res = TestHttpResponse.parse(parts) + self.assertEqual(res.code, 200) + self.assertEqual(res.get_http_name(), "HTTP") + json.loads(res.body.decode()) diff --git a/tests/serverdata_test.py b/tests/serverdata_test.py new file mode 100644 index 00000000..0756ae57 --- /dev/null +++ b/tests/serverdata_test.py @@ -0,0 +1,46 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest +import json + +from homekit.accessoryserver import AccessoryServerData +import tempfile + + +class TestServerData(unittest.TestCase): + def test_example2_1_1(self): + fp = tempfile.NamedTemporaryFile(mode="w") + data = { + "host_ip": "12.34.56.78", + "host_port": 4711, + "c#": 1, + "category": "bidge", + "accessory_pin": "123-45-678", + "accessory_pairing_id": "12:34:56:78:90:AB", + "name": "test007", + "unsuccessful_tries": 0, + } + json.dump(data, fp) + fp.flush() + + hksd = AccessoryServerData(fp.name) + self.assertEqual(hksd.accessory_pairing_id_bytes, b"12:34:56:78:90:AB") + pk = bytes([0x12, 0x34]) + sk = bytes([0x56, 0x78]) + hksd.set_accessory_keys(pk, sk) + self.assertEqual(hksd.accessory_ltpk, pk) + self.assertEqual(hksd.accessory_ltsk, sk) diff --git a/tests/serviceTypes_test.py b/tests/serviceTypes_test.py new file mode 100644 index 00000000..a19bb365 --- /dev/null +++ b/tests/serviceTypes_test.py @@ -0,0 +1,67 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.model.services import ServicesTypes + + +class TestServiceTypes(unittest.TestCase): + def test_getitem_forward(self): + self.assertEqual( + ServicesTypes["3E"], "public.hap.service.accessory-information" + ) + + def test_getitem_reverse(self): + self.assertEqual( + ServicesTypes["public.hap.service.accessory-information"], "3E" + ) + + def test_getitem_notfound(self): + self.assertEqual(ServicesTypes["1337"], "Unknown Service: 1337") + + def test_get_short(self): + self.assertEqual( + ServicesTypes.get_short("00000086-0000-1000-8000-0026BB765291"), "occupancy" + ) + + def test_get_short_lowercase(self): + self.assertEqual( + ServicesTypes.get_short("00000086-0000-1000-8000-0026bb765291"), "occupancy" + ) + + def test_get_short_no_baseid(self): + self.assertEqual( + ServicesTypes.get_short("00000023-0000-1000-8000-NOTBASEID"), + "Unknown Service: 00000023-0000-1000-8000-NOTBASEID", + ) + + def test_get_short_no_service(self): + self.assertEqual( + ServicesTypes.get_short("00000023-0000-1000-8000-0026BB765291"), + "Unknown Service: 00000023-0000-1000-8000-0026BB765291", + ) + + def test_get_uuid(self): + self.assertEqual( + ServicesTypes.get_uuid("public.hap.service.doorbell"), + "00000121-0000-1000-8000-0026BB765291", + ) + + def test_get_uuid_no_service(self): + self.assertRaises( + Exception, ServicesTypes.get_uuid, "public.hap.service.NO_A_SERVICE" + ) diff --git a/tests/srp_test.py b/tests/srp_test.py new file mode 100644 index 00000000..0e90488a --- /dev/null +++ b/tests/srp_test.py @@ -0,0 +1,47 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.crypto.srp import SrpServer, SrpClient + + +class TestSrp(unittest.TestCase): + def test_1(self): + # step M1 + + # step M2 + setup_code = "123-45-678" # transmitted on second channel + server = SrpServer("Pair-Setup", setup_code) + server_pub_key = server.get_public_key() + server_salt = server.get_salt() + + # step M3 + client = SrpClient("Pair-Setup", setup_code) + client.set_salt(server_salt) + client.set_server_public_key(server_pub_key) + + client_pub_key = client.get_public_key() + clients_proof = client.get_proof() + + # step M4 + server.set_client_public_key(client_pub_key) + server.get_shared_secret() + self.assertTrue(server.verify_clients_proof(clients_proof)) + servers_proof = server.get_proof(clients_proof) + + # step M5 + self.assertTrue(client.verify_servers_proof(servers_proof)) diff --git a/tests/tlv_test.py b/tests/tlv_test.py new file mode 100644 index 00000000..fe70223b --- /dev/null +++ b/tests/tlv_test.py @@ -0,0 +1,213 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest + +from homekit.protocol.tlv import TLV, TlvParseException + + +class TestTLV(unittest.TestCase): + def test_long_values_1(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Certificate, (300 * "a").encode()], + [TLV.kTLVType_Identifier, "hello".encode()], + ] + res = TLV.decode_bytearray(TLV.encode_list(val)) + self.assertEqual(val, res) + + def test_long_values_2(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Certificate, (150 * "a" + 150 * "b").encode()], + [TLV.kTLVType_Identifier, "hello".encode()], + ] + res = TLV.decode_bytearray(TLV.encode_list(val)) + self.assertEqual(val, res) + + def test_long_values_decode_bytearray_to_list(self): + example = bytearray.fromhex( + "060103" + ("09FF" + 255 * "61" + "092D" + 45 * "61") + "010568656c6c6f" + ) + expected = [ + [6, bytearray(b"\x03")], + [9, bytearray(300 * b"a")], + [1, bytearray(b"hello")], + ] + + data = TLV.decode_bytearray(example) + self.assertListEqual(data, expected) + + def test_long_values_decode_bytes_to_list(self): + example = bytes( + bytearray.fromhex( + "060103" + ("09FF" + 255 * "61" + "092D" + 45 * "61") + "010568656c6c6f" + ) + ) + expected = [ + [6, bytearray(b"\x03")], + [9, bytearray(300 * b"a")], + [1, bytearray(b"hello")], + ] + + data = TLV.decode_bytes(example) + self.assertListEqual(data, expected) + + # def test_long_values_decode_bytearray(self): + # example = bytearray.fromhex('060103' + ('09FF' + 255 * '61' + '092D' + 45 * '61') + '010568656c6c6f') + # expected = { + # 6: bytearray(b'\x03'), + # 9: bytearray(300 * b'a'), + # 1: bytearray(b'hello') + # } + # + # data = TLV.decode_bytearray(example) + # self.assertDictEqual(data, expected) + # + # def test_decode_bytearray_not_enough_data(self): + # example = bytearray.fromhex('060103' + '09FF' + 25 * '61') # should have been 255 '61' + # self.assertRaises(TlvParseException, TLV.decode_bytearray, example) + + def test_decode_bytearray_to_list_not_enough_data(self): + example = bytearray.fromhex( + "060103" + "09FF" + 25 * "61" + ) # should have been 255 '61' + self.assertRaises(TlvParseException, TLV.decode_bytearray, example) + + def test_decode_bytes_to_list_not_enough_data(self): + example = bytes( + bytearray.fromhex("060103" + "09FF" + 25 * "61") + ) # should have been 255 '61' + self.assertRaises(TlvParseException, TLV.decode_bytes, example) + + def test_encode_list_key_error(self): + example = [ + (-1, "hello",), + ] + self.assertRaises(ValueError, TLV.encode_list, example) + example = [ + (256, "hello",), + ] + self.assertRaises(ValueError, TLV.encode_list, example) + example = [ + ("test", "hello",), + ] + self.assertRaises(ValueError, TLV.encode_list, example) + + def test_to_string_for_list(self): + example = [ + (1, "hello",), + ] + res = TLV.to_string(example) + self.assertEqual(res, "[\n 1: (5 bytes/) hello\n]\n") + example = [ + (1, "hello",), + (2, "world",), + ] + res = TLV.to_string(example) + self.assertEqual( + res, + "[\n 1: (5 bytes/) hello\n 2: (5 bytes/) world\n]\n", + ) + + def test_to_string_for_dict(self): + example = {1: "hello"} + res = TLV.to_string(example) + self.assertEqual(res, "{\n 1: (5 bytes/) hello\n}\n") + example = {1: "hello", 2: "world"} + res = TLV.to_string(example) + self.assertEqual( + res, + "{\n 1: (5 bytes/) hello\n 2: (5 bytes/) world\n}\n", + ) + + def test_to_string_for_dict_bytearray(self): + example = {1: bytearray([0x42, 0x23])} + res = TLV.to_string(example) + self.assertEqual(res, "{\n 1: (2 bytes/) 0x4223\n}\n") + + def test_to_string_for_list_bytearray(self): + example = [[1, bytearray([0x42, 0x23])]] + res = TLV.to_string(example) + self.assertEqual(res, "[\n 1: (2 bytes/) 0x4223\n]\n") + + def test_separator_list(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + TLV.kTLVType_Separator_Pair, + [TLV.kTLVType_State, TLV.M4], + ] + res = TLV.decode_bytearray(TLV.encode_list(val)) + self.assertEqual(val, res) + + def test_separator_list_error(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Separator, "test"], + [TLV.kTLVType_State, TLV.M4], + ] + self.assertRaises(ValueError, TLV.encode_list, val) + + def test_reorder_1(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Salt, (16 * "a").encode()], + [TLV.kTLVType_PublicKey, (384 * "b").encode()], + ] + tmp = TLV.reorder( + val, [TLV.kTLVType_State, TLV.kTLVType_PublicKey, TLV.kTLVType_Salt] + ) + self.assertEqual(tmp[0][0], TLV.kTLVType_State) + self.assertEqual(tmp[0][1], TLV.M3) + self.assertEqual(tmp[1][0], TLV.kTLVType_PublicKey) + self.assertEqual(tmp[1][1], (384 * "b").encode()) + self.assertEqual(tmp[2][0], TLV.kTLVType_Salt) + self.assertEqual(tmp[2][1], (16 * "a").encode()) + + def test_reorder_2(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Salt, (16 * "a").encode()], + [TLV.kTLVType_PublicKey, (384 * "b").encode()], + ] + tmp = TLV.reorder(val, [TLV.kTLVType_State, TLV.kTLVType_Salt]) + self.assertEqual(tmp[0][0], TLV.kTLVType_State) + self.assertEqual(tmp[0][1], TLV.M3) + self.assertEqual(tmp[1][0], TLV.kTLVType_Salt) + self.assertEqual(tmp[1][1], (16 * "a").encode()) + + def test_reorder_3(self): + val = [ + [TLV.kTLVType_State, TLV.M3], + [TLV.kTLVType_Salt, (16 * "a").encode()], + [TLV.kTLVType_PublicKey, (384 * "b").encode()], + ] + tmp = TLV.reorder( + val, [TLV.kTLVType_State, TLV.kTLVType_Error, TLV.kTLVType_Salt] + ) + self.assertEqual(tmp[0][0], TLV.kTLVType_State) + self.assertEqual(tmp[0][1], TLV.M3) + self.assertEqual(tmp[1][0], TLV.kTLVType_Salt) + self.assertEqual(tmp[1][1], (16 * "a").encode()) + + def test_filter(self): + example = bytes(bytearray.fromhex("060103" + "010203")) + expected = [ + [6, bytearray(b"\x03")], + ] + + data = TLV.decode_bytes(example, expected=[6]) + self.assertListEqual(data, expected) diff --git a/tests/zeroconf_test.py b/tests/zeroconf_test.py new file mode 100644 index 00000000..851f10cd --- /dev/null +++ b/tests/zeroconf_test.py @@ -0,0 +1,179 @@ +# +# Copyright 2019 aiohomekit team +# +# 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 unittest +from zeroconf import Zeroconf, ServiceInfo +import socket + +from homekit.zeroconf_impl import ( + find_device_ip_and_port, + discover_homekit_devices, + get_from_properties, +) + + +class TestZeroconf(unittest.TestCase): + @staticmethod + def find_device(desc, result): + test_device = None + for device in result: + device_found = True + for key in desc: + expected_val = desc[key] + if device[key] != expected_val: + device_found = False + break + if device_found: + test_device = device + break + return test_device + + def test_find_without_device(self): + result = find_device_ip_and_port("00:00:00:00:00:00", 1) + self.assertIsNone(result) + + def test_find_with_device(self): + zeroconf = Zeroconf() + desc = {"id": "00:00:02:00:00:02"} + info = ServiceInfo( + "_hap._tcp.local.", + "foo1._hap._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=1234, + properties=desc, + weight=0, + priority=0, + ) + zeroconf.unregister_all_services() + zeroconf.register_service(info, allow_name_change=True) + + result = find_device_ip_and_port("00:00:02:00:00:02", 10) + + zeroconf.unregister_all_services() + + self.assertIsNotNone(result) + self.assertEqual(result["ip"], "127.0.0.1") + + def test_discover_homekit_devices(self): + zeroconf = Zeroconf() + desc = { + "c#": "1", + "id": "00:00:01:00:00:02", + "md": "unittest", + "s#": "1", + "ci": "5", + "sf": "0", + } + info = ServiceInfo( + "_hap._tcp.local.", + "foo2._hap._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=1234, + properties=desc, + weight=0, + priority=0, + ) + zeroconf.unregister_all_services() + zeroconf.register_service(info, allow_name_change=True) + + result = discover_homekit_devices() + test_device = self.find_device(desc, result) + + zeroconf.unregister_all_services() + + self.assertIsNotNone(test_device) + + def test_discover_homekit_devices_missing_c(self): + zeroconf = Zeroconf() + desc = { + "id": "00:00:01:00:00:03", + "md": "unittest", + "s#": "1", + "ci": "5", + "sf": "0", + } + info = ServiceInfo( + "_hap._tcp.local.", + "foo3._hap._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=1234, + properties=desc, + weight=0, + priority=0, + ) + zeroconf.unregister_all_services() + zeroconf.register_service(info, allow_name_change=True) + + result = discover_homekit_devices() + test_device = self.find_device(desc, result) + + zeroconf.unregister_all_services() + + self.assertIsNone(test_device) + + def test_discover_homekit_devices_missing_md(self): + zeroconf = Zeroconf() + desc = {"c#": "1", "id": "00:00:01:00:00:04", "s#": "1", "ci": "5", "sf": "0"} + info = ServiceInfo( + "_hap._tcp.local.", + "foo4._hap._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=1234, + properties=desc, + weight=0, + priority=0, + ) + zeroconf.unregister_all_services() + zeroconf.register_service(info, allow_name_change=True) + + result = discover_homekit_devices() + test_device = self.find_device(desc, result) + + zeroconf.unregister_all_services() + + self.assertIsNone(test_device) + + def test_existing_key(self): + props = {"c#": "259"} + val = get_from_properties(props, "c#") + self.assertEqual("259", val) + + def test_non_existing_key_no_default(self): + props = {"c#": "259"} + val = get_from_properties(props, "s#") + self.assertEqual(None, val) + + def test_non_existing_key_case_insensitive(self): + props = {"C#": "259", "heLLo": "World"} + val = get_from_properties(props, "c#") + self.assertEqual(None, val) + val = get_from_properties(props, "c#", case_sensitive=True) + self.assertEqual(None, val) + val = get_from_properties(props, "c#", case_sensitive=False) + self.assertEqual("259", val) + + val = get_from_properties(props, "HEllo", case_sensitive=False) + self.assertEqual("World", val) + + def test_non_existing_key_with_default(self): + props = {"c#": "259"} + val = get_from_properties(props, "s#", default="1") + self.assertEqual("1", val) + + def test_non_existing_key_with_default_non_string(self): + props = {"c#": "259"} + val = get_from_properties(props, "s#", default=1) + self.assertEqual("1", val)