From d85749b6016584a568301f3297df372caf9c4540 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Mar 2021 23:33:54 -1000 Subject: [PATCH 01/13] Remove legacy python code (#321) --- pyhap/encoder.py | 32 +++++++++++++------------- pyhap/util.py | 58 ++++++++++-------------------------------------- 2 files changed, 28 insertions(+), 62 deletions(-) diff --git a/pyhap/encoder.py b/pyhap/encoder.py index fd84388e..b7612c66 100644 --- a/pyhap/encoder.py +++ b/pyhap/encoder.py @@ -8,8 +8,6 @@ import ed25519 -from pyhap.util import fromhex, tohex - class AccessoryEncoder: """This class defines the Accessory encoder interface. @@ -52,14 +50,15 @@ def persist(fp, state): - UUID and public key of paired clients. - Config version. """ - paired_clients = {str(client): tohex(key) - for client, key in state.paired_clients.items()} + paired_clients = { + str(client): bytes.hex(key) for client, key in state.paired_clients.items() + } config_state = { - 'mac': state.mac, - 'config_version': state.config_version, - 'paired_clients': paired_clients, - 'private_key': tohex(state.private_key.to_seed()), - 'public_key': tohex(state.public_key.to_bytes()), + "mac": state.mac, + "config_version": state.config_version, + "paired_clients": paired_clients, + "private_key": bytes.hex(state.private_key.to_seed()), + "public_key": bytes.hex(state.public_key.to_bytes()), } json.dump(config_state, fp) @@ -70,10 +69,11 @@ def load_into(fp, state): @see: AccessoryEncoder.persist """ loaded = json.load(fp) - state.mac = loaded['mac'] - state.config_version = loaded['config_version'] - state.paired_clients = {uuid.UUID(client): fromhex(key) - for client, key in - loaded['paired_clients'].items()} - state.private_key = ed25519.SigningKey(fromhex(loaded['private_key'])) - state.public_key = ed25519.VerifyingKey(fromhex(loaded['public_key'])) + state.mac = loaded["mac"] + state.config_version = loaded["config_version"] + state.paired_clients = { + uuid.UUID(client): bytes.fromhex(key) + for client, key in loaded["paired_clients"].items() + } + state.private_key = ed25519.SigningKey(bytes.fromhex(loaded["private_key"])) + state.public_key = ed25519.VerifyingKey(bytes.fromhex(loaded["public_key"])) diff --git a/pyhap/util.py b/pyhap/util.py index bf97ba5c..dad1e573 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -1,13 +1,10 @@ import asyncio import base64 -import socket import random -import binascii -import sys - +import socket -ALPHANUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' -HEX_DIGITS = '0123456789ABCDEF' +ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +HEX_DIGITS = "0123456789ABCDEF" rand = random.SystemRandom() @@ -65,7 +62,8 @@ def generate_mac(): :rtype: str """ return "{}{}:{}{}:{}{}:{}{}:{}{}:{}{}".format( - *(rand.choice(HEX_DIGITS) for _ in range(12))) + *(rand.choice(HEX_DIGITS) for _ in range(12)) + ) def generate_setup_id(): @@ -77,10 +75,7 @@ def generate_setup_id(): :return: 4 digit alphanumeric code. :rtype: str """ - return ''.join([ - rand.choice(ALPHANUM) - for i in range(4) - ]) + return "".join([rand.choice(ALPHANUM) for i in range(4)]) def generate_pincode(): @@ -90,50 +85,21 @@ def generate_pincode(): :return: pincode in format ``xxx-xx-xxx`` :rtype: bytearray """ - return '{}{}{}-{}{}-{}{}{}'.format( - *(rand.randint(0, 9) for i in range(8)) - ).encode('ascii') - - -def b2hex(bts): - """Produce a hex string representation of the given bytes. - - :param bts: bytes to convert to hex. - :type bts: bytes - :rtype: str - """ - return binascii.hexlify(bts).decode("ascii") - - -def hex2b(hex_str): - """Produce bytes from the given hex string representation. - - :param hex: hex string - :type hex: str - :rtype: bytes - """ - return binascii.unhexlify(hex_str.encode("ascii")) - - -tohex = bytes.hex if sys.version_info >= (3, 5) else b2hex -"""Python-version-agnostic tohex function. Equivalent to bytes.hex in python 3.5+. -""" - -fromhex = bytes.fromhex if sys.version_info >= (3, 5) else hex2b -"""Python-version-agnostic fromhex function. Equivalent to bytes.fromhex in python 3.5+. -""" + return "{}{}{}-{}{}-{}{}{}".format(*(rand.randint(0, 9) for i in range(8))).encode( + "ascii" + ) def to_base64_str(bytes_input) -> str: - return base64.b64encode(bytes_input).decode('utf-8') + return base64.b64encode(bytes_input).decode("utf-8") def base64_to_bytes(str_input) -> bytes: - return base64.b64decode(str_input.encode('utf-8')) + return base64.b64decode(str_input.encode("utf-8")) def byte_bool(boolv): - return b'\x01' if boolv else b'\x00' + return b"\x01" if boolv else b"\x00" async def event_wait(event, timeout, loop=None): From 6405e52c38fefb684faf9e89de60481a55ef0e64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Mar 2021 23:35:12 -1000 Subject: [PATCH 02/13] Remove deprecated get_char_loader and get_serv_loader (#322) --- pyhap/loader.py | 44 ++++++++++---------------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/pyhap/loader.py b/pyhap/loader.py index 5591f4a6..620b76a6 100644 --- a/pyhap/loader.py +++ b/pyhap/loader.py @@ -25,8 +25,7 @@ class Loader: .. seealso:: pyhap/resources/characteristics.json """ - def __init__(self, path_char=CHARACTERISTICS_FILE, - path_service=SERVICES_FILE): + def __init__(self, path_char=CHARACTERISTICS_FILE, path_service=SERVICES_FILE): """Initialize a new Loader instance.""" self.char_types = self._read_file(path_char) self.serv_types = self._read_file(path_service) @@ -34,24 +33,25 @@ def __init__(self, path_char=CHARACTERISTICS_FILE, @staticmethod def _read_file(path): """Read file and return a dict.""" - with open(path, 'r') as file: + with open(path, "r") as file: return json.load(file) def get_char(self, name): """Return new Characteristic object.""" char_dict = self.char_types[name].copy() - if 'Format' not in char_dict or \ - 'Permissions' not in char_dict or \ - 'UUID' not in char_dict: - raise KeyError('Could not load char {}!'.format(name)) + if ( + "Format" not in char_dict + or "Permissions" not in char_dict + or "UUID" not in char_dict + ): + raise KeyError("Could not load char {}!".format(name)) return Characteristic.from_dict(name, char_dict) def get_service(self, name): """Return new service object.""" service_dict = self.serv_types[name].copy() - if 'RequiredCharacteristics' not in service_dict or \ - 'UUID' not in service_dict: - raise KeyError('Could not load service {}!'.format(name)) + if "RequiredCharacteristics" not in service_dict or "UUID" not in service_dict: + raise KeyError("Could not load service {}!".format(name)) return Service.from_dict(name, service_dict, self) @classmethod @@ -73,27 +73,3 @@ def get_loader(): if _loader is None: _loader = Loader() return _loader - - -# pylint: disable=unused-argument -def get_char_loader(desc_file=None): - """Get a CharacteristicLoader with characteristic descriptions in the given file. - - .. deprecated:: 2.0 - Use `get_loader` instead. - """ - logger.warning( - "'get_char_loader' is deprecated. Use 'get_loader' instead.") - return get_loader() - - -# pylint: disable=unused-argument -def get_serv_loader(desc_file=None): - """Get a ServiceLoader with service descriptions in the given file. - - .. deprecated:: 2.0 - Use `get_loader` instead. - """ - logger.warning( - "'get_serv_loader' is deprecated. Use 'get_loader' instead.") - return get_loader() From f4198db3ec9d740c9e9b5c0b27fc8203cd7ec80e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Mar 2021 23:36:49 -1000 Subject: [PATCH 03/13] Fix accessory run not being awaited from a bridge (#323) --- pyhap/accessory.py | 49 +++-------------- tests/__init__.py | 9 ++++ tests/test_accessory.py | 115 +++++++++++++++++++++++++++++++++++----- 3 files changed, 116 insertions(+), 57 deletions(-) diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 578ac42b..4896c325 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -2,15 +2,15 @@ import itertools import logging -from pyhap import util, SUPPORT_QR_CODE +from pyhap import SUPPORT_QR_CODE, util from pyhap.const import ( - STANDALONE_AID, + CATEGORY_BRIDGE, + CATEGORY_OTHER, HAP_REPR_AID, HAP_REPR_IID, HAP_REPR_SERVICES, HAP_REPR_VALUE, - CATEGORY_OTHER, - CATEGORY_BRIDGE, + STANDALONE_AID, ) from pyhap.iid_manager import IIDManager @@ -25,9 +25,6 @@ class Accessory: """A representation of a HAP accessory. Inherit from this class to build your own accessories. - - At the end of the init of this class, the _set_services method is called. - Use this to set your HAP services. """ category = CATEGORY_OTHER @@ -51,7 +48,6 @@ def __init__(self, driver, display_name, aid=None): self.iid_manager = IIDManager() self.add_info_service() - self._set_services() def __repr__(self): """Return the representation of the accessory.""" @@ -60,19 +56,6 @@ def __repr__(self): self.display_name, services ) - def __getstate__(self): - state = self.__dict__.copy() - state["driver"] = None - state["run_sentinel"] = None - return state - - def _set_services(self): - """Set the services for this accessory. - - .. deprecated:: 2.0 - Initialize the service inside the accessory `init` method instead. - """ - @property def available(self): """Accessory is available. @@ -107,7 +90,7 @@ def set_info_service( serv_info.configure_char("Manufacturer", value=manufacturer) if model: serv_info.configure_char("Model", value=model) - if serial_number: + if serial_number is not None: if len(serial_number) >= 1: serv_info.configure_char("SerialNumber", value=serial_number) else: @@ -133,26 +116,6 @@ def set_primary_service(self, primary_service): for service in self.services: service.is_primary_service = service.type_id == primary_service.type_id - def config_changed(self): - """Notify the accessory about configuration changes. - - These include new services or updated characteristic values, e.g. - the Name of a service changed. - - This method also notifies the driver about the change, so that it can - publish the changes to the world. - - .. note:: If you are changing the configuration of a bridged accessory - (i.e. an Accessory that is contained in a Bridge), - you should call the `config_changed` method on the Bridge. - - Deprecated. Use `driver.config_changed()` instead. - """ - logger.warning( - "This method is now deprecated. Use 'driver.config_changed' instead." - ) - self.driver.config_changed() - def add_service(self, *servs): """Add the given services to this Accessory. @@ -405,7 +368,7 @@ def get_characteristic(self, aid, iid): async def run(self): """Schedule tasks for each of the accessories' run method.""" for acc in self.accessories.values(): - self.driver.async_add_job(acc.run) + await self.driver.async_add_job(acc.run) async def stop(self): """Calls stop() on all contained accessories.""" diff --git a/tests/__init__.py b/tests/__init__.py index 399d748e..6477f057 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,5 @@ import os +from unittest.mock import MagicMock # Absolutize paths to coverage config and output file because tests that # spawn subprocesses also changes current working directory. @@ -8,3 +9,11 @@ os.environ["COV_CORE_CONFIG"] = os.path.join( _sourceroot, os.environ["COV_CORE_CONFIG"] ) + + +# Remove this when we drop python 3.5/3.6 support +class AsyncMock(MagicMock): + async def __call__( + self, *args, **kwargs + ): # pylint: disable=useless-super-delegation,invalid-overridden-method + return super().__call__(*args, **kwargs) diff --git a/tests/test_accessory.py b/tests/test_accessory.py index 3882fdf4..c4eebe04 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -1,6 +1,10 @@ """Tests for pyhap.accessory.""" +from io import StringIO +from unittest.mock import patch + import pytest +from pyhap import accessory from pyhap.accessory import Accessory, Bridge from pyhap.const import ( CATEGORY_CAMERA, @@ -8,8 +12,11 @@ CATEGORY_TELEVISION, STANDALONE_AID, ) +from pyhap.service import Service from pyhap.state import State +from . import AsyncMock + # #### Accessory ###### # execute with `-k acc` # ##################### @@ -27,20 +34,6 @@ def test_acc_publish_no_broker(mock_driver): char.set_value(25, should_notify=True) -def test_acc_set_services_compatible(mock_driver): - """Test deprecated method _set_services.""" - - class Acc(Accessory): - def _set_services(self): - super()._set_services() - serv = self.driver.loader.get_service("TemperatureSensor") - self.add_service(serv) - - acc = Acc(mock_driver, "Test Accessory") - assert acc.get_service("AccessoryInformation") is not None - assert acc.get_service("TemperatureSensor") is not None - - def test_acc_set_primary_service(mock_driver): """Test method set_primary_service.""" acc = Accessory(mock_driver, "Test Accessory") @@ -56,6 +49,23 @@ def test_acc_set_primary_service(mock_driver): assert acc.get_service("TelevisionSpeaker").is_primary_service is False +def test_acc_add_preload_service_without_chars(mock_driver): + """Test method add_preload_service.""" + acc = Accessory(mock_driver, "Test Accessory") + + serv = acc.add_preload_service("Television") + assert isinstance(serv, Service) + + +def test_acc_add_preload_service_with_chars(mock_driver): + """Test method add_preload_service with additional chars.""" + acc = Accessory(mock_driver, "Test Accessory") + + serv = acc.add_preload_service("Television", chars=["ActiveIdentifier"]) + assert isinstance(serv, Service) + assert serv.get_characteristic("ActiveIdentifier") is not None + + # #### Bridge ############ # execute with `-k bridge` # ######################## @@ -92,6 +102,31 @@ def test_bridge_n_add_accessory_dup_aid(mock_driver): bridge.add_accessory(acc_2) +@patch("sys.stdout", new_callable=StringIO) +def test_setup_message_without_qr_code(mock_stdout, mock_driver): + """Verify we print out the setup code.""" + acc = Accessory(mock_driver, "Test Accessory", aid=STANDALONE_AID) + mock_driver.state = State( + address="1.2.3.4", mac="AA::BB::CC::DD::EE", pincode=b"653-32-1211", port=44 + ) + with patch.object(accessory, "SUPPORT_QR_CODE", False): + acc.setup_message() + assert "653-32-1211" in mock_stdout.getvalue() + + +@patch("sys.stdout", new_callable=StringIO) +def test_setup_message_with_qr_code(mock_stdout, mock_driver): + """Verify we can print out a QR code.""" + acc = Accessory(mock_driver, "Test Accessory", aid=STANDALONE_AID) + mock_driver.state = State( + address="1.2.3.4", mac="AA::BB::CC::DD::EE", pincode=b"653-32-1211", port=44 + ) + with patch.object(accessory, "SUPPORT_QR_CODE", True): + acc.setup_message() + assert "653-32-1211" in mock_stdout.getvalue() + assert "\x1b[7m" in mock_stdout.getvalue() + + def test_xhm_uri(mock_driver): acc_1 = Accessory(mock_driver, "Test Accessory 1", aid=2) acc_1.category = CATEGORY_CAMERA @@ -126,6 +161,43 @@ def test_set_info_service(mock_driver): assert serv_info.get_characteristic("SerialNumber").value == "serial" +def test_set_info_service_empty(mock_driver): + acc_1 = Accessory(mock_driver, "Test Accessory 1", aid=2) + acc_1.set_info_service() + serv_info = acc_1.get_service("AccessoryInformation") + assert serv_info.get_characteristic("FirmwareRevision").value == "" + assert serv_info.get_characteristic("Manufacturer").value == "" + assert serv_info.get_characteristic("Model").value == "" + assert serv_info.get_characteristic("SerialNumber").value == "default" + + +def test_set_info_service_invalid_serial(mock_driver): + acc_1 = Accessory(mock_driver, "Test Accessory 1", aid=2) + acc_1.set_info_service(serial_number="") + serv_info = acc_1.get_service("AccessoryInformation") + assert serv_info.get_characteristic("FirmwareRevision").value == "" + assert serv_info.get_characteristic("Manufacturer").value == "" + assert serv_info.get_characteristic("Model").value == "" + assert serv_info.get_characteristic("SerialNumber").value == "default" + + +def test_get_characteristic(mock_driver): + bridge = Bridge(mock_driver, "Test Bridge") + acc = Accessory(mock_driver, "Test Accessory", aid=2) + assert acc.available is True + assert bridge.aid == 1 + assert bridge.get_characteristic(1, 2).display_name == "Identify" + assert bridge.get_characteristic(2, 2) is None + assert bridge.get_characteristic(3, 2) is None + + +def test_cannot_add_bridge_to_bridge(mock_driver): + bridge = Bridge(mock_driver, "Test Bridge") + bridge2 = Bridge(mock_driver, "Test Bridge") + with pytest.raises(ValueError): + bridge.add_accessory(bridge2) + + def test_to_hap(mock_driver): bridge = Bridge(mock_driver, "Test Bridge") acc = Accessory(mock_driver, "Test Accessory", aid=2) @@ -308,3 +380,18 @@ def test_to_hap(mock_driver): } ], } + + +@pytest.mark.asyncio +async def test_bridge_run_stop(mock_driver): + mock_driver.async_add_job = AsyncMock() + bridge = Bridge(mock_driver, "Test Bridge") + acc = Accessory(mock_driver, "Test Accessory", aid=2) + assert acc.available is True + bridge.add_accessory(acc) + acc2 = Accessory(mock_driver, "Test Accessory 2") + bridge.add_accessory(acc2) + + await bridge.run() + assert mock_driver.async_add_job.called + await bridge.stop() From e8920d8450039e30bdaf0a4298405574f532018f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Mar 2021 23:39:04 -1000 Subject: [PATCH 04/13] Cleanup subscriptions on client disconnect (#324) --- pyhap/accessory_driver.py | 18 ++++++++++++++++++ pyhap/hap_protocol.py | 1 + tests/test_accessory_driver.py | 2 ++ tests/test_hap_protocol.py | 22 ++++++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index f92b4b24..3809fd63 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -431,6 +431,24 @@ def async_subscribe_client_topic(self, client, topic, subscribe=True): if not subscribed_clients: del self.topics[topic] + def connection_lost(self, client): + """Called when a connection is lost to a client. + + This method must be run in the event loop. + + :param client: A client (address, port) tuple that should be unsubscribed. + :type client: tuple + """ + client_topics = [] + for topic, subscribed_clients in self.topics.items(): + if client in subscribed_clients: + # Make a copy to avoid changing + # self.topics during iteration + client_topics.append(topic) + + for topic in client_topics: + self.async_subscribe_client_topic(client, topic, subscribe=False) + def publish(self, data, sender_client_addr=None): """Publishes an event to the client. diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index dd869b8f..5632a58f 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -40,6 +40,7 @@ def connection_lost(self, exc: Exception) -> None: self.accessory_driver.accessory.display_name, exc, ) + self.accessory_driver.connection_lost(self.peername) self.close() def connection_made(self, transport: asyncio.Transport) -> None: diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index c0429e3a..fef06d2d 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -507,6 +507,8 @@ def test_async_subscribe_client_topic(driver): assert driver.topics == {topic: {addr_info}} driver.async_subscribe_client_topic(addr_info, topic, False) assert driver.topics == {} + driver.async_subscribe_client_topic(addr_info, "invalid", False) + assert driver.topics == {} def test_mdns_service_info(driver): diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 8b17a26a..9f72bce8 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -36,9 +36,21 @@ def test_connection_management(driver): """Verify closing the connection removes it from the pool.""" loop = MagicMock() addr_info = ("1.2.3.4", 5) + addr_info2 = ("1.2.3.5", 6) + transport = MagicMock(get_extra_info=Mock(return_value=addr_info)) connections = {} driver.add_accessory(Accessory(driver, "TestAcc")) + driver.async_subscribe_client_topic(addr_info, "1.1", True) + driver.async_subscribe_client_topic(addr_info, "2.2", True) + driver.async_subscribe_client_topic(addr_info2, "1.1", True) + + assert "1.1" in driver.topics + assert "2.2" in driver.topics + + assert addr_info in driver.topics["1.1"] + assert addr_info in driver.topics["2.2"] + assert addr_info2 in driver.topics["1.1"] hap_proto = hap_protocol.HAPServerProtocol(loop, connections, driver) hap_proto.connection_made(transport) @@ -46,6 +58,10 @@ def test_connection_management(driver): assert connections[addr_info] == hap_proto hap_proto.connection_lost(None) assert len(connections) == 0 + assert "1.1" in driver.topics + assert "2.2" not in driver.topics + assert addr_info not in driver.topics["1.1"] + assert addr_info2 in driver.topics["1.1"] hap_proto.connection_made(transport) assert len(connections) == 1 @@ -53,6 +69,12 @@ def test_connection_management(driver): hap_proto.close() assert len(connections) == 0 + hap_proto.connection_made(transport) + assert len(connections) == 1 + assert connections[addr_info] == hap_proto + hap_proto.connection_lost(None) + assert len(connections) == 0 + def test_pair_setup(driver): """Verify an non-encrypt request.""" From dcfe160520774ac44154fb748be485943aae49d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 07:54:30 -1000 Subject: [PATCH 05/13] Add coverage for SRP (#326) --- pyhap/hsrp.py | 180 +++++++++++++++++++++------------------------ pyhap/params.py | 97 ++++-------------------- tests/test_hsrp.py | 65 ++++++++++++++++ 3 files changed, 161 insertions(+), 181 deletions(-) create mode 100644 tests/test_hsrp.py diff --git a/pyhap/hsrp.py b/pyhap/hsrp.py index 0bf75485..4c4dcb5c 100644 --- a/pyhap/hsrp.py +++ b/pyhap/hsrp.py @@ -1,126 +1,112 @@ -# An incomplete implementation of SRP (i.e. the server side of SRP). -# I remember there was a problem with an srp module that I used -# as a guideline. -# TODO: make it a complete implementation. +# Server Side SRP implementation + import os from .util import long_to_bytes -# -# s - bytes -# x - int -# k - int -# K - int -# S - int -# u - bytes -# p - bytes - - -def padN(bytestr, ctx): - return bytestr.rjust(ctx["N_len"] // 8, b'\x00') - - -def _bytes_to_long(s): - n = ord(s[0]) - for b in (ord(x) for x in s[1:]): - n = (n << 8) | b - return n - def bytes_to_long(s): # Bytes should be interpreted from left to right, hence the byteorder return int.from_bytes(s, byteorder="big") -def get_x(u, p, s, ctx): - hf = ctx["hashfunc"]() - hf.update(u + b":" + p) - up = hf.digest() - hf = ctx["hashfunc"]() - hf.update(s + up) - return int(hf.hexdigest(), 16) +# b Secret ephemeral values (long) +# A Public ephemeral values (long) +# Ab Public ephemeral values (bytes) +# B Public ephemeral values (long) +# Bb Public ephemeral values (bytes) +# g A generator modulo N (long) +# gb A generator modulo N (bytes) +# I Username (bytes) +# k Multiplier parameter (long) +# N Large safe prime (long) +# Nb Large safe prime (bytes) +# p Cleartext Password (bytes) +# s Salt (bytes) +# u Random scrambling parameter (bytes) +# v Password verifier (long) + + +class Server: + def __init__(self, ctx, u, p, s=None, v=None, b=None): + self.hashfunc = ctx["hashfunc"] + self.N = ctx["N"] + self.Nb = long_to_bytes(self.N) + self.g = ctx["g"] + self.gb = long_to_bytes(self.g) + self.N_len = ctx["N_len"] + self.s = s or os.urandom(ctx["salt_len"]) + self.I = u # noqa: E741 + self.p = p + self.v = v or self._get_verifier() + self.k = self._get_k() + self.b = b or bytes_to_long(os.urandom(ctx["secret_len"])) + self.B = self._derive_B() + self.Bb = long_to_bytes(self.B) + + self.Ab = None + self.A = None + self.S = None + self.Sb = None + self.K = None + self.Kb = None + self.M = None + self.u = None + self.HAMK = None + def _digest(self, data): + return self.hashfunc(data).digest() -def get_verifier(u, p, s, ctx): - x = get_x(u, p, s, ctx) - return pow(ctx['g'], x, ctx['N']) + def _hexdigest_int16(self, data): + return int(self.hashfunc(data).hexdigest(), 16) + def _derive_B(self): + return (self.k * self.v + pow(self.g, self.b, self.N)) % self.N -def get_k(ctx): - hf = ctx["hashfunc"]() - hf.update(long_to_bytes(ctx["N"]) + padN(long_to_bytes(ctx["g"]), ctx)) - return int(hf.hexdigest(), 16) + def _get_private_key(self): + return self._hexdigest_int16(self.s + self._digest(self.I + b":" + self.p)) + def _get_verifier(self): + return pow(self.g, self._get_private_key(), self.N) -def get_session_key(S, ctx): - hf = ctx['hashfunc']() - hf.update(long_to_bytes(S)) - return int(hf.hexdigest(), 16) + def _get_k(self): + return self._hexdigest_int16(self.Nb + self._padN(self.gb)) + def _get_K(self): + return self._hexdigest_int16(self.Sb) -class Server(): + def _padN(self, bytestr): + return bytestr.rjust(self.N_len // 8, b"\x00") - def __init__(self, ctx, u, p, s=None, v=None): - self.ctx = ctx - self.u = u - self.p = p - self.s = s or os.urandom(self.ctx["salt_len"]) - self.v = v or get_verifier(u, p, self.s, self.ctx) - self.k = get_k(ctx) - self.b = bytes_to_long(os.urandom(256)) # TODO: specify length - self.B = self.derive_B() - self.A = None - self.S = None - self.K = None - self.M = None - self.HAMK = None + def _derive_premaster_secret(self): + self.u = self._hexdigest_int16(self._padN(self.Ab) + self._padN(self.Bb)) + Avu = self.A * pow(self.v, self.u, self.N) + return pow(Avu, self.b, self.N) - def derive_B(self): - return (self.k * self.v + pow(self.ctx["g"], self.b, self.ctx["N"])) \ - % self.ctx["N"] + def _get_M(self): + hN = self._digest(self.Nb) + hG = self._digest(self.gb) + hGroup = bytes(hN[i] ^ hG[i] for i in range(0, len(hN))) + hU = self._digest(self.I) + return self._digest(hGroup + hU + self.s + self.Ab + self.Bb + self.Kb) def set_A(self, bytes_A): - self.A = int.from_bytes(bytes_A, byteorder="big") - self.S = self.derive_premaster_secret() - self.K = get_session_key(self.S, self.ctx) - self.M = self.get_M() + self.A = bytes_to_long(bytes_A) + self.Ab = bytes_A + self.S = self._derive_premaster_secret() + self.Sb = long_to_bytes(self.S) + self.K = self._get_K() + self.Kb = long_to_bytes(self.K) + self.M = self._get_M() + self.HAMK = self._get_HAMK() + + def _get_HAMK(self): + return self._digest(self.Ab + self.M + self.Kb) def get_challenge(self): return (self.s, self.B) - def derive_premaster_secret(self): - hf = self.ctx['hashfunc']() - hf.update(padN(long_to_bytes(self.A), self.ctx) + - padN(long_to_bytes(self.B), self.ctx)) - U = int(hf.hexdigest(), 16) - Avu = self.A * pow(self.v, U, self.ctx["N"]) - return pow(Avu, self.b, self.ctx["N"]) - - def get_M(self): - hf = self.ctx['hashfunc']() - hf.update(long_to_bytes(self.ctx['N'])) - hN = hf.digest() - hf = self.ctx['hashfunc']() - hf.update(long_to_bytes(self.ctx['g'])) - hG = hf.digest() - hGroup = bytes(hN[i] ^ hG[i] for i in range(0, len(hN))) - hf = self.ctx['hashfunc']() - hf.update(self.u) - hU = hf.digest() - hf = self.ctx['hashfunc']() - hf.update(hGroup + hU + self.s + long_to_bytes(self.A) + - long_to_bytes(self.B) + long_to_bytes(self.K)) - return hf.digest() - def verify(self, M): - if self.M != M: - return None - self.HAMK = self.get_HAMK() - return self.HAMK - - def get_HAMK(self): - hf = self.ctx['hashfunc']() - hf.update(long_to_bytes(self.A) + self.M + long_to_bytes(self.K)) - return hf.digest() + return self.HAMK if self.M == M else None def get_session_key(self): return self.K diff --git a/pyhap/params.py b/pyhap/params.py index 9d76ca73..d06a9d78 100644 --- a/pyhap/params.py +++ b/pyhap/params.py @@ -1,24 +1,10 @@ # hsrp parameters -ng_order = (1024, 2048, 3072, 4096, 8192) +ng_order = (3072,) _ng_const = ( - # 1024-bit - ('''\ -EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576D674DF7496\ -EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD15DC7D7B46154D6B6CE8E\ -F4AD69B15D4982559B297BCF1885C529F566660E57EC68EDBC3C05726CC02FD4CBF4976EAA\ -9AFD5138FE8376435B9FC61D2FC0EB06E3''', "2"), - # 2048 - ('''\ -AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4\ -A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\ -95179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF\ -747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B907\ -8717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB37861\ -60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\ -FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73''', "2"), # 3072 - ('''\ + ( + """\ FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08\ 8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B\ 302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9\ @@ -32,79 +18,22 @@ B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226\ 1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C\ BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC\ -E0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF''', "5"), - # 4096 - ('''\ -FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08\ -8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B\ -302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9\ -A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE6\ -49286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8\ -FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D\ -670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C\ -180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\ -3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D\ -04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D\ -B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226\ -1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C\ -BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC\ -E0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B26\ -99C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB\ -04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2\ -233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127\ -D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199\ -FFFFFFFFFFFFFFFF''', "5"), - # 8192 - ('''\ -FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08\ -8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B\ -302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9\ -A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE6\ -49286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8\ -FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D\ -670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C\ -180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\ -3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D\ -04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D\ -B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226\ -1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C\ -BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC\ -E0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B26\ -99C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB\ -04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2\ -233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127\ -D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934028492\ -36C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BDF8FF9406\ -AD9E530EE5DB382F413001AEB06A53ED9027D831179727B0865A8918\ -DA3EDBEBCF9B14ED44CE6CBACED4BB1BDB7F1447E6CC254B33205151\ -2BD7AF426FB8F401378CD2BF5983CA01C64B92ECF032EA15D1721D03\ -F482D7CE6E74FEF6D55E702F46980C82B5A84031900B1C9E59E7C97F\ -BEC7E8F323A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AA\ -CC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE32806A1D58B\ -B7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55CDA56C9EC2EF29632\ -387FE8D76E3C0468043E8F663F4860EE12BF2D5B0B7474D6E694F91E\ -6DBE115974A3926F12FEE5E438777CB6A932DF8CD8BEC4D073B931BA\ -3BC832B68D9DD300741FA7BF8AFC47ED2576F6936BA424663AAB639C\ -5AE4F5683423B4742BF1C978238F16CBE39D652DE3FDB8BEFC848AD9\ -22222E04A4037C0713EB57A81A23F0C73473FC646CEA306B4BCBC886\ -2F8385DDFA9D4B7FA2C087E879683303ED5BDD3A062B3CF5B3A278A6\ -6D2A13F83F44F82DDF310EE074AB6A364597E899A0255DC164F31CC5\ -0846851DF9AB48195DED7EA1B1D510BD7EE74D73FAF36BC31ECFA268\ -359046F4EB879F924009438B481C6CD7889A002ED5EE382BC9190DA6\ -FC026E479558E4475677E9AA9E3050E2765694DFC81F56E880B96E71\ -60C980DD98EDD3DFFFFFFFFFFFFFFFFF''', '0x13') +E0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF""", + "5", + ), ) -def get_srp_context(ng_group_len, hashfunc, salt_len=16): +def get_srp_context(ng_group_len, hashfunc, salt_len=16, secret_len=32): group = _ng_const[ng_order.index(ng_group_len)] ctx = { - 'hashfunc': hashfunc, - 'N': int(group[0], 16), - 'g': int(group[1], 16), - 'N_len': ng_group_len, - 'salt_len': salt_len + "hashfunc": hashfunc, + "N": int(group[0], 16), + "g": int(group[1], 16), + "N_len": ng_group_len, + "salt_len": salt_len, + "secret_len": secret_len, } return ctx diff --git a/tests/test_hsrp.py b/tests/test_hsrp.py new file mode 100644 index 00000000..faec9cc0 --- /dev/null +++ b/tests/test_hsrp.py @@ -0,0 +1,65 @@ +"""Tests for pyhap.hsrp.""" + +# pylint: disable=line-too-long, pointless-string-statement + +import hashlib +from pyhap.hsrp import Server +from pyhap.params import get_srp_context +from pyhap.util import long_to_bytes + +DUMMY_A = b"Ve\xce\xd4\x90LExKD\x9d7\x16\\@\xb6\xb8\x9f\x01\x1a]\x86\xa4\x1c" +" \x13\xaa\xc0\x17=\x1f\xafPx\xea/\x01Q\xc8hw\x06\x03\xc8O\x89|\x8d4\xa8\x85" +"\xd2\xfb:\x0e\xb6PT2V\xb2\xa9\xca\x0bL\x97\r\xee\x88\xbc\xef\x8d\xa6|\xeb \xdc" +"\x80.\x92\xe0\xe5s\xf5\xf2;\x89LN\\^\x8c\xd1\x00\x99U]]/^\xe9\x1b\xe2\xf3\x1a|" +"\xc6\x85Q\x95T`b\x8e\x04\xc2\x99\xdd\xdfp\x98\x85\x13\xe5\xaf\xdf\xe0Tm\xa3t\xfe" +"\xc1_V\x04\xab\xb1\x96\xa8\x9cw\xa40\x95\x8d\x9f|\xf7.\x90\xd2{L\xcc*\xcb\xdde" +"\x81\x14\x14\xc97\xe7\xa0177\x1b\xe0\xb0\x19\x0f\xf1\x1e;\xc4\xc9\x07\x05zN\xb3" +"!y\xf2\x9e\xa4N\xbeswxx\x13\x82\x18\xccU\xb4\xec\x7f{\x8eo\x86\x0b\xa6\xff\x9b" +"\xbcY(0\x16\xba$\x9d\xb9\x8d}\xe5f\x0c)\\\x8b\\\xef\xfd\x0coEg\x13\x13\xa2q\xb9" +"\xe5\x8a\xfd\x97\x97\xcb\xb1\x15\xd5\xc2\xd7\x07\x91A\xdf\xd7" + + +def test_srp_basic(): + + ctx = get_srp_context(3072, hashlib.sha512, 16) + b = 191304991611724068381190663629083136274 + s = long_to_bytes(227710976386754876301088769828140156049) + verifier = Server(ctx, b"Pair-Setup", b"123-45-543", s=s, b=b) + verifier.set_A(DUMMY_A) + + assert ( + verifier.k + == 8891118944006259431156568541843809053371474718154946070525699599564743247786811275097952247025117806925219847643897478119979876683245412022290811230509536 + ) + assert ( + verifier.get_session_key() + == 7776966363435436003301596680621751479448170893927097125414524508260409807602643597201957531811064094375727460485526402929080964822225092649470633176208468 + ) + assert ( + verifier.M + == b"\xafnZ\xef\x8e\x84\xbe\xaa\xe2M}5'\x0c\xb8\xb9\x07\x13\xa3t\xbbfOL\x059\xa3T\xaf\x021\x05\xf7*\xdb]\xa3]\x92\xbc\xa7\x0ed\xc1C\x88W\x0b\xe7n\xe6|\x1e\xb4\xf9pUc\xa2\x8d\x05\xd7\xabI" + ) + assert ( + verifier.S + == 74327940101639752536537640881643581886247890122995727869092918508085397047960192114187184206420245499227933354038262980545757154896143196917567791395562849790585173129051928488506985432588320936161016609993624725221069849383124728580710793131421162926844621384309691065416908669855286020750380619018007734494245389837285359061649585082978114606737696983003789452193299203880220013003551748645087934186574940836315605161763958706985646740794424371115818479937015467439653789667600114913036877616558029128521276071759153575011083182650027094873442901697309464533625147028860476977419766721379872518101123122550406587162809198793634217353529574423908555799363233330194347012490634061830786590780000201696990820985363093141614397601285773980430681705777477946555312165250133963931282621724675380164859592461132141730419315498467050491890312826221069184134326282895963295397215898192608240385050625017941322853973472354023693355 + ) + assert verifier.get_challenge() == ( + s, + 2149981971605054722971448928513305504744266471818820776094113337432031877014471028912971746321748621185649001880451734094103311676264091997241948096711710461140721738956497494552388614895831596671069609694220554015991913746528757304239759620571367574036184864989138266792823575841594621160010011666017298902208272126405229578664943728094068949021795802799552486045670159066273942547651088762352104942364707580142387716636468281068738042936130578774565386637668610429058884417819388838110075674266297699354845325023954873162742733169560666501210723876454859556564325607870517213063038111644227553599978606540729093082921723443122696487068510228710655880466038292327450357013882323502992655150615829432843408599038481983277372215619348128412279375677793332715557041679298014663382481619951610899087031959653365603032111634191603851554865349816117884573658915813848292512124719015181912892538210471183790840676306564839828444134, + ) + assert verifier.b == b + assert ( + verifier.v + == 1800954445588585461785592179273284825501707649217210015435034845050179016324355419526711292364866248346582448660643272322280999760562622718989053886869428917425675795172391329924178337579968214001782222575897907780437717763112406095878356902641567396545009429496128133564692965499069074320017151157469160990771527712530637370897276672652870613312504255873634362188551282649472569433062597795005057270622772410668342950279555516133010272639201733492626622809480021268951287298118968011031850511105359580984350020671780470982743318615303989055956125558514263378948829479434245711743458681522240763520911255733079164391662778946744155477806679057949726211652108387739564473209550264487697151825509058193841809273482575660658239177704074882302955007248950743262054925817705066654613816236610736311934089570249355454459951577900707115340781119430461780455828980205046091360390327787803271426555681638302650021637121212829077894589 + ) + assert ( + verifier.N + == 5809605995369958062791915965639201402176612226902900533702900882779736177890990861472094774477339581147373410185646378328043729800750470098210924487866935059164371588168047540943981644516632755067501626434556398193186628990071248660819361205119793693985433297036118232914410171876807536457391277857011849897410207519105333355801121109356897459426271845471397952675959440793493071628394122780510124618488232602464649876850458861245784240929258426287699705312584509625419513463605155428017165714465363094021609290561084025893662561222573202082865797821865270991145082200656978177192827024538990239969175546190770645685893438011714430426409338676314743571154537142031573004276428701433036381801705308659830751190352946025482059931306571004727362479688415574702596946457770284148435989129632853918392117997472632693078113129886487399347796982772784615865232621289656944284216824611318709764535152507354116344703769998514148343807 + ) + assert verifier.g == 5 + + assert ( + verifier.verify(verifier.M) + == b"\xe1\x00\xcf\xe2\x98\xaf\x1e\x02tb\x0b\xfclKF\xee\x1b\x80\xf6\x90\xb7\x8a\x9f\x133y#>\x8d/\xc1\x88\x93\x8eh\tN\x9b\xda\xc2-\x1a(\xe3\xca\x0bf\xf3\xc4\xca\xc4\xec\xfa/\xec\xb7\x16\x81\xdd%\xc9i\xf9\x90" + ) + assert verifier.verify(b"wrong") is None From 8858789d1c907f8aa2864769d5c8a5ca40891db8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 08:04:02 -1000 Subject: [PATCH 06/13] Support python 3.10 (#328) --- .github/workflows/ci.yaml | 4 ++-- README.md | 2 +- pyhap/accessory_driver.py | 31 ++++++++++++++++++-------- pyhap/util.py | 4 ++-- pyproject.toml | 2 +- setup.py | 3 +++ tests/conftest.py | 12 ++++++++-- tests/test_accessory_driver.py | 40 ++++++++++++++++++++++++++-------- tox.ini | 4 +++- 9 files changed, 75 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c730b08..61139072 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10.0-alpha.5"] steps: - uses: actions/checkout@v1 @@ -35,7 +35,7 @@ jobs: strategy: matrix: - python-version: [3.7] + python-version: [3.9] steps: - uses: actions/checkout@v1 diff --git a/README.md b/README.md index 8fa5262d..fcca1fd3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![PyPI version](https://badge.fury.io/py/HAP-python.svg)](https://badge.fury.io/py/HAP-python) [![Build Status](https://github.com/ikalchev/HAP-python/workflows/CI/badge.svg)](https://github.com/ikalchev/HAP-python) [![codecov](https://codecov.io/gh/ikalchev/HAP-python/branch/master/graph/badge.svg)](https://codecov.io/gh/ikalchev/HAP-python) [![Documentation Status](https://readthedocs.org/projects/hap-python/badge/?version=latest)](http://hap-python.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/hap-python)](https://pepy.tech/project/hap-python) +[![PyPI version](https://badge.fury.io/py/HAP-python.svg)](https://badge.fury.io/py/HAP-python) [![Build Status](https://github.com/ikalchev/HAP-python/workflows/CI/badge.svg)](https://github.com/ikalchev/HAP-python) [![codecov](https://codecov.io/gh/ikalchev/HAP-python/branch/master/graph/badge.svg)](https://codecov.io/gh/ikalchev/HAP-python) [![Python Versions](https://img.shields.io/pypi/pyversions/HAP-python.svg)](https://pypi.python.org/pypi/HAP-python/) [![Documentation Status](https://readthedocs.org/projects/hap-python/badge/?version=latest)](http://hap-python.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/hap-python)](https://pepy.tech/project/hap-python) # HAP-python HomeKit Accessory Protocol implementation in python 3. diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 3809fd63..ea93713b 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -231,7 +231,7 @@ def __init__( self.encoder = encoder or AccessoryEncoder() self.topics = {} # topic: set of (address, port) of subscribed clients self.loader = loader or Loader() - self.aio_stop_event = asyncio.Event(loop=loop) + self.aio_stop_event = None self.stop_event = threading.Event() self.safe_mode = False @@ -266,7 +266,7 @@ def start(self): "Not setting a child watcher. Set one if " "subprocesses will be started outside the main thread." ) - self.add_job(self.start_service) + self.add_job(self.async_start()) self.loop.run_forever() except KeyboardInterrupt: logger.debug("Got a KeyboardInterrupt, stopping driver") @@ -277,6 +277,19 @@ def start(self): logger.info("Closed the event loop") def start_service(self): + """Start the service.""" + self._validate_start() + self.add_job(self.async_start) + + def _validate_start(self): + """Validate we can start.""" + if self.accessory is None: + raise ValueError( + "You must assign an accessory to the driver, " + "before you can start it." + ) + + async def async_start(self): """Starts the accessory. - Call the accessory's run method. @@ -288,11 +301,9 @@ def start_service(self): All of the above are started in separate threads. Accessory thread is set as daemon. """ - if self.accessory is None: - raise ValueError( - "You must assign an accessory to the driver, " - "before you can start it." - ) + self._validate_start() + self.aio_stop_event = asyncio.Event() + logger.info( "Starting accessory %s on address %s, port %s.", self.accessory.display_name, @@ -302,12 +313,14 @@ def start_service(self): # Start listening for requests logger.debug("Starting server.") - self.add_job(self.http_server.async_start, self.loop) + await self.http_server.async_start(self.loop) # Advertise the accessory as a mDNS service. logger.debug("Starting mDNS.") self.mdns_service_info = AccessoryMDNSServiceInfo(self.accessory, self.state) - self.advertiser.register_service(self.mdns_service_info) + await self.loop.run_in_executor( + None, self.advertiser.register_service, self.mdns_service_info + ) # Print accessory setup message if not self.state.paired: diff --git a/pyhap/util.py b/pyhap/util.py index dad1e573..630bd3b7 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -102,7 +102,7 @@ def byte_bool(boolv): return b"\x01" if boolv else b"\x00" -async def event_wait(event, timeout, loop=None): +async def event_wait(event, timeout): """Wait for the given event to be set or for the timeout to expire. :param event: The event to wait for. @@ -115,7 +115,7 @@ async def event_wait(event, timeout, loop=None): :rtype: bool """ try: - await asyncio.wait_for(event.wait(), timeout, loop=loop) + await asyncio.wait_for(event.wait(), timeout) except asyncio.TimeoutError: pass return event.is_set() diff --git a/pyproject.toml b/pyproject.toml index 6a97c073..64a572d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.black] -target-version = ["py37", "py38"] +target-version = ["py35", "py36", "py37", "py38"] exclude = 'generated' [tool.isort] diff --git a/setup.py b/setup.py index 51206124..919855b6 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,9 @@ 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries :: Python Modules', ], diff --git a/tests/conftest.py b/tests/conftest.py index 50fe5980..dc809c46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,8 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.loader import Loader +from . import AsyncMock + @pytest.fixture(scope="session") def mock_driver(): @@ -21,9 +23,15 @@ def driver(): except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - with patch("pyhap.accessory_driver.HAPServer"), patch( + with patch( + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + ), patch( + "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock + ), patch( "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): yield AccessoryDriver(loop=loop) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index fef06d2d..239f3d48 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1,9 +1,9 @@ """Tests for pyhap.accessory_driver.""" import asyncio +from concurrent.futures import ThreadPoolExecutor import tempfile from unittest.mock import MagicMock, patch from uuid import uuid1 -from concurrent.futures import ThreadPoolExecutor import pytest @@ -30,6 +30,8 @@ from pyhap.service import Service from pyhap.state import State +from . import AsyncMock + CHAR_PROPS = { PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: HAP_PERMISSION_READ, @@ -402,9 +404,15 @@ def setup_message(self): @pytest.mark.asyncio async def test_start_stop_sync_acc(): - with patch("pyhap.accessory_driver.HAPServer"), patch( + with patch( + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + ), patch( + "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock + ), patch( "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ), patch( "pyhap.accessory_driver.AccessoryDriver.load" ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) @@ -430,9 +438,15 @@ def setup_message(self): @pytest.mark.asyncio async def test_start_stop_async_acc(): """Verify run_at_interval closes the driver.""" - with patch("pyhap.accessory_driver.HAPServer"), patch( + with patch( + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + ), patch( + "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock + ), patch( "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ), patch( "pyhap.accessory_driver.AccessoryDriver.load" ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) @@ -464,6 +478,7 @@ def test_start_without_accessory(driver): def test_send_events(driver): """Test we can send events.""" + driver.aio_stop_event = MagicMock(is_set=MagicMock(return_value=False)) class LoopMock: runcount = 0 @@ -542,15 +557,21 @@ def test_mdns_service_info(driver): @pytest.mark.asyncio async def test_start_service_and_update_config(): """Test starting service and updating the config.""" - with patch("pyhap.accessory_driver.HAPServer"), patch( + with patch( + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + ), patch( + "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock + ), patch( "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ), patch( "pyhap.accessory_driver.AccessoryDriver.load" ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) acc = Accessory(driver, "TestAcc") driver.add_accessory(acc) - driver.start_service() + await driver.async_start() assert driver.state.config_version == 2 driver.config_changed() @@ -558,7 +579,8 @@ async def test_start_service_and_update_config(): driver.state.config_version = 65535 driver.config_changed() assert driver.state.config_version == 1 - + for _ in range(3): + await asyncio.sleep(0) await driver.async_stop() await asyncio.sleep(0) assert not driver.loop.is_closed() diff --git a/tox.ini b/tox.ini index bfd89142..4b613c26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, py37, py38, docs, lint, pylint +envlist = py35, py36, py37, py38, py39, py310, docs, lint, pylint skip_missing_interpreters = True [gh-actions] @@ -8,6 +8,8 @@ python = 3.6: py36 3.7: py37 3.8: py38, mypy + 3.9: py39, mypy + 3.10: py310, mypy [testenv] deps = From 6a99c6d15a0cf45c0262b6631cd78e4ecfee5188 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 08:04:50 -1000 Subject: [PATCH 07/13] Increase coverage for async util functions (#325) --- pyhap/accessory_driver.py | 28 +++--------- pyhap/util.py | 15 +++++++ tests/test_accessory_driver.py | 79 ++++++++++++++++++++++++++++++++++ tests/test_util.py | 31 +++++++++++++ 4 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 tests/test_util.py diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index ea93713b..69daf79a 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -18,16 +18,15 @@ import asyncio import base64 from concurrent.futures import ThreadPoolExecutor -import functools import hashlib -import tempfile import json import logging import os +import re import socket import sys +import tempfile import threading -import re from zeroconf import ServiceInfo, Zeroconf @@ -35,7 +34,6 @@ from pyhap.accessory import get_topic from pyhap.characteristic import CharacteristicError from pyhap.const import ( - MAX_CONFIG_VERSION, HAP_PERMISSION_NOTIFY, HAP_REPR_ACCS, HAP_REPR_AID, @@ -43,6 +41,7 @@ HAP_REPR_IID, HAP_REPR_STATUS, HAP_REPR_VALUE, + MAX_CONFIG_VERSION, STANDALONE_AID, ) from pyhap.encoder import AccessoryEncoder @@ -51,6 +50,7 @@ from pyhap.loader import Loader from pyhap.params import get_srp_context from pyhap.state import State + from .util import callback logger = logging.getLogger(__name__) @@ -64,20 +64,6 @@ VALID_MDNS_REGEX = re.compile(r"[^A-Za-z0-9\-]+") -def is_callback(func): - """Check if function is callback.""" - return "_pyhap_callback" in getattr(func, "__dict__", {}) - - -def iscoro(func): - """Check if the function is a coroutine or if the function is a ``functools.partial``, - check the wrapped function for the same. - """ - if isinstance(func, functools.partial): - func = func.func - return asyncio.iscoroutinefunction(func) - - class AccessoryMDNSServiceInfo(ServiceInfo): """A mDNS service info representation of an accessory.""" @@ -335,7 +321,7 @@ async def async_start(self): def stop(self): """Method to stop pyhap.""" - self.loop.call_soon_threadsafe(self.loop.create_task, self.async_stop()) + self.add_job(self.async_stop) async def async_stop(self): """Stops the AccessoryDriver and shutdown all remaining tasks.""" @@ -389,9 +375,9 @@ def async_add_job(self, target, *args): if asyncio.iscoroutine(target): task = self.loop.create_task(target) - elif is_callback(target): + elif util.is_callback(target): self.loop.call_soon(target, *args) - elif iscoro(target): + elif util.iscoro(target): task = self.loop.create_task(target(*args)) else: task = self.loop.run_in_executor(None, target, *args) diff --git a/pyhap/util.py b/pyhap/util.py index 630bd3b7..4c5d2e29 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -2,6 +2,7 @@ import base64 import random import socket +import functools ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" HEX_DIGITS = "0123456789ABCDEF" @@ -15,6 +16,20 @@ def callback(func): return func +def is_callback(func): + """Check if function is callback.""" + return "_pyhap_callback" in getattr(func, "__dict__", {}) + + +def iscoro(func): + """Check if the function is a coroutine or if the function is a ``functools.partial``, + check the wrapped function for the same. + """ + if isinstance(func, functools.partial): + func = func.func + return asyncio.iscoroutinefunction(func) + + def get_local_address(): """ Grabs the local IP address using a socket. diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 239f3d48..bca90f28 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -7,6 +7,7 @@ import pytest +from pyhap import util from pyhap.accessory import STANDALONE_AID, Accessory, Bridge from pyhap.accessory_driver import ( SERVICE_COMMUNICATION_FAILURE, @@ -470,6 +471,33 @@ def setup_message(self): assert not driver.loop.is_closed() +@pytest.mark.asyncio +async def test_start_from_async_stop_from_executor(): + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + "pyhap.accessory_driver.AccessoryDriver.load" + ): + driver = AccessoryDriver(loop=asyncio.get_event_loop()) + run_event = asyncio.Event() + + class Acc(Accessory): + @Accessory.run_at_interval(0) + def run(self): # pylint: disable=invalid-overridden-method + run_event.set() + + def setup_message(self): + pass + + acc = Acc(driver, "TestAcc") + driver.add_accessory(acc) + driver.start_service() + await run_event.wait() + assert not driver.loop.is_closed() + await driver.loop.run_in_executor(None, driver.stop) + await driver.aio_stop_event.wait() + + def test_start_without_accessory(driver): """Verify we throw ValueError if there is no accessory.""" with pytest.raises(ValueError): @@ -585,3 +613,54 @@ async def test_start_service_and_update_config(): await asyncio.sleep(0) assert not driver.loop.is_closed() assert driver.aio_stop_event.is_set() + + +def test_call_add_job_with_none(driver): + """Test calling add job with none.""" + with pytest.raises(ValueError): + driver.add_job(None) + + +@pytest.mark.asyncio +async def test_call_async_add_job_with_coroutine(driver): + """Test calling async_add_job with a coroutine.""" + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + "pyhap.accessory_driver.AccessoryDriver.load" + ): + driver = AccessoryDriver(loop=asyncio.get_event_loop()) + called = False + + async def coro_test(): + nonlocal called + called = True + + await driver.async_add_job(coro_test) + assert called is True + + called = False + await driver.async_add_job(coro_test()) + assert called is True + + +@pytest.mark.asyncio +async def test_call_async_add_job_with_callback(driver): + """Test calling async_add_job with a coroutine.""" + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + "pyhap.accessory_driver.AccessoryDriver.load" + ): + driver = AccessoryDriver(loop=asyncio.get_event_loop()) + called = False + + @util.callback + def callback_test(): + nonlocal called + called = True + + driver.async_add_job(callback_test) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert called is True diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..984b4ab4 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,31 @@ +"""Test for pyhap.util.""" +import functools + +from pyhap import util + + +@util.callback +def async_is_callback(): + """Test callback.""" + + +def async_not_callback(): + """Test callback.""" + + +async def async_function(): + """Test for iscoro.""" + + +def test_callback(): + """Test is_callback.""" + assert util.is_callback(async_is_callback) is True + assert util.is_callback(async_not_callback) is False + + +def test_iscoro(): + """Test iscoro.""" + assert util.iscoro(async_function) is True + assert util.iscoro(functools.partial(async_function)) is True + assert util.iscoro(async_is_callback) is False + assert util.iscoro(async_not_callback) is False From 4f91df0095652a4f0354981d65effe2355983e48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 09:13:02 -1000 Subject: [PATCH 08/13] Resolve connection stability with large responses (#320) --- pyhap/hap_handler.py | 14 ++------- pyhap/hap_protocol.py | 34 +++++++++++++++++++++ pyhap/hap_server.py | 20 ++++++++++++- tests/test_accessory_driver.py | 10 +++++-- tests/test_hap_handler.py | 55 ++++++++++++++++++++++++++++++++++ tests/test_hap_protocol.py | 45 ++++++++++++++++++++++++++++ tests/test_hap_server.py | 30 ++++++++++++++++++- 7 files changed, 192 insertions(+), 16 deletions(-) diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 7f790e98..04c79e36 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -14,9 +14,10 @@ import curve25519 import ed25519 +from pyhap.const import CATEGORY_BRIDGE import pyhap.tlv as tlv from pyhap.util import long_to_bytes -from pyhap.const import CATEGORY_BRIDGE + from .hap_crypto import hap_hkdf, pad_tls_nonce SNAPSHOT_TIMEOUT = 10 @@ -94,10 +95,6 @@ class HAP_PERMISSIONS: ADMIN = b"\x01" -class TimeoutException(Exception): - pass - - class UnprivilegedRequestException(Exception): pass @@ -228,8 +225,6 @@ def dispatch(self, request, body=None): self.send_response_with_status( HTTPStatus.UNAUTHORIZED, HAP_SERVER_STATUS.INSUFFICIENT_PRIVILEGES ) - except TimeoutException: - self.send_response_with_status(500, HAP_SERVER_STATUS.OPERATION_TIMED_OUT) except Exception: # pylint: disable=broad-except logger.exception( "%s: Failed to process request for: %s", self.client_address, path @@ -239,11 +234,6 @@ def dispatch(self, request, body=None): HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, ) - body_len = len(self.response.body) - if body_len: - # Force Content-Length as iOS can sometimes - # stall if it gets chunked encoding - self.send_header("Content-Length", str(body_len)) self.response = None return response diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 5632a58f..9c4d2760 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -4,6 +4,7 @@ """ import asyncio import logging +import time from cryptography.exceptions import InvalidTag import h11 @@ -13,6 +14,14 @@ logger = logging.getLogger(__name__) +HIGH_WRITE_BUFFER_SIZE = 2 ** 19 +# We timeout idle connections after 90 hours as we must +# clean up unused sockets periodically. 90 hours was choosen +# as its the longest time we expect a user to be away from +# their phone or device before they have to resync when they +# reopen homekit. +IDLE_CONNECTION_TIMEOUT_SECONDS = 90 * 60 * 60 + class HAPServerProtocol(asyncio.Protocol): """A asyncio.Protocol implementing the HAP protocol.""" @@ -30,6 +39,7 @@ def __init__(self, loop, connections, accessory_driver) -> None: self.request_body = None self.response = None + self.last_activity = None self.hap_crypto = None def connection_lost(self, exc: Exception) -> None: @@ -45,12 +55,17 @@ def connection_lost(self, exc: Exception) -> None: def connection_made(self, transport: asyncio.Transport) -> None: """Handle incoming connection.""" + self.last_activity = time.time() peername = transport.get_extra_info("peername") logger.info( "%s: Connection made to %s", peername, self.accessory_driver.accessory.display_name, ) + # Ensure we do not write a partial encrypted response + # as it can cause the controller to send a RST and drop + # the connection with large responses. + transport.set_write_buffer_limits(high=HIGH_WRITE_BUFFER_SIZE) self.transport = transport self.peername = peername self.connections[peername] = self @@ -58,6 +73,7 @@ def connection_made(self, transport: asyncio.Transport) -> None: def write(self, data: bytes) -> None: """Write data to the client.""" + self.last_activity = time.time() if self.hap_crypto: result = self.hap_crypto.encrypt(data) logger.debug("%s: Send encrypted: %s", self.peername, data) @@ -74,6 +90,11 @@ def close(self) -> None: def send_response(self, response: HAPResponse) -> None: """Send a HAPResponse object.""" + body_len = len(response.body) + if body_len: + # Force Content-Length as iOS can sometimes + # stall if it gets chunked encoding + response.headers.append(("Content-Length", str(body_len))) self.write( self.conn.send( h11.Response( @@ -86,8 +107,21 @@ def send_response(self, response: HAPResponse) -> None: + self.conn.send(h11.EndOfMessage()) ) + def check_idle(self, now) -> None: + """Abort when do not get any data within the timeout.""" + if self.last_activity + IDLE_CONNECTION_TIMEOUT_SECONDS >= now: + return + logger.info( + "%s: Idle time out after %s to %s", + self.peername, + IDLE_CONNECTION_TIMEOUT_SECONDS, + self.accessory_driver.accessory.display_name, + ) + self.close() + def data_received(self, data: bytes) -> None: """Process new data from the socket.""" + self.last_activity = time.time() if self.hap_crypto: self.hap_crypto.receive_data(data) try: diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index b796d530..e9f44b24 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -4,12 +4,15 @@ """ import logging +import time -from .util import callback from .hap_protocol import HAPServerProtocol +from .util import callback logger = logging.getLogger(__name__) +IDLE_CONNECTION_CHECK_INTERVAL_SECONDS = 120 + class HAPServer: """Point of contact for HAP clients. @@ -52,14 +55,28 @@ def __init__(self, addr_port, accessory_handler): self.accessory_handler = accessory_handler self.server = None self._serve_task = None + self._connection_cleanup = None + self.loop = None async def async_start(self, loop): """Start the http-hap server.""" + self.loop = loop self.server = await loop.create_server( lambda: HAPServerProtocol(loop, self.connections, self.accessory_handler), self._addr_port[0], self._addr_port[1], ) + self.async_cleanup_connections() + + @callback + def async_cleanup_connections(self): + """Cleanup stale connections.""" + now = time.time() + for hap_proto in list(self.connections.values()): + hap_proto.check_idle(now) + self._connection_cleanup = self.loop.call_later( + IDLE_CONNECTION_CHECK_INTERVAL_SECONDS, self.async_cleanup_connections + ) @callback def async_stop(self): @@ -72,6 +89,7 @@ def async_stop(self): if hap_server_protocol: hap_server_protocol.close() self.connections.clear() + self._connection_cleanup.cancel() def push_event(self, bytesdata, client_addr): """Send an event to the current connection with the provided data. diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index bca90f28..39f163ae 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -473,9 +473,15 @@ def setup_message(self): @pytest.mark.asyncio async def test_start_from_async_stop_from_executor(): - with patch("pyhap.accessory_driver.HAPServer"), patch( + with patch( + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + ), patch( + "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock + ), patch( "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ), patch( "pyhap.accessory_driver.AccessoryDriver.load" ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index d675d753..217fd9e4 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -1,12 +1,14 @@ """Tests for the HAPServerHandler.""" +from unittest.mock import patch from uuid import UUID import pytest from pyhap import hap_handler from pyhap.accessory import Accessory, Bridge +from pyhap.characteristic import CharacteristicError import pyhap.tlv as tlv CLIENT_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1") @@ -260,6 +262,30 @@ def test_pair_verify_two_invaild_state(driver): } +def test_invalid_pairing_request(driver): + """Verify an unencrypted pair verify with an invalid sequence fails.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + driver.pair( + CLIENT_UUID, + PUBLIC_KEY, + ) + assert CLIENT_UUID in driver.state.paired_clients + + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M6, + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, + PUBLIC_KEY, + ) + with pytest.raises(ValueError): + handler.handle_pair_verify() + + def test_handle_set_handle_set_characteristics_unencrypted(driver): """Verify an unencrypted set_characteristics.""" acc = Accessory(driver, "TestAcc", aid=1) @@ -364,3 +390,32 @@ def test_attempt_to_pair_when_already_paired(driver): hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M2, hap_handler.HAP_TLV_TAGS.ERROR_CODE: hap_handler.HAP_TLV_ERRORS.UNAVAILABLE, } + + +def test_handle_get_characteristics_encrypted(driver): + """Verify an encrypted get_characteristics.""" + acc = Accessory(driver, "TestAcc", aid=1) + assert acc.aid == 1 + service = acc.driver.loader.get_service("GarageDoorOpener") + acc.add_service(service) + driver.add_accessory(acc) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = True + + response = hap_handler.HAPResponse() + handler.response = response + handler.path = "/characteristics?id=1.9" + handler.handle_get_characteristics() + + assert response.status_code == 207 + assert b'"value": 0' in response.body + + with patch.object(acc.iid_manager, "get_obj", side_effect=CharacteristicError): + response = hap_handler.HAPResponse() + handler.response = response + handler.path = "/characteristics?id=1.9" + handler.handle_get_characteristics() + + assert response.status_code == 207 + assert b"-70402" in response.body diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 9f72bce8..a7d4970b 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -1,5 +1,6 @@ """Tests for the HAPServerProtocol.""" import asyncio +import time from unittest.mock import MagicMock, Mock, patch from cryptography.exceptions import InvalidTag @@ -563,3 +564,47 @@ async def test_camera_snapshot_missing_accessory(driver): assert hap_proto.response is None assert b"-70402" in writer.call_args_list[0][0][0] hap_proto.close() + + +@pytest.mark.asyncio +async def test_idle_timeout(driver): + """Test we close the connection once we reach the idle timeout.""" + loop = asyncio.get_event_loop() + transport = MagicMock() + connections = {} + driver.add_accessory(Accessory(driver, "TestAcc")) + + hap_proto = hap_protocol.HAPServerProtocol(loop, connections, driver) + hap_proto.connection_made(transport) + + with patch.object(hap_protocol, "IDLE_CONNECTION_TIMEOUT_SECONDS", 0), patch.object( + hap_proto, "close" + ) as hap_proto_close, patch.object(hap_proto.transport, "write") as writer: + hap_proto.data_received( + b"POST /pair-setup HTTP/1.1\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\nContent-Length: 6\r\nContent-Type: application/pairing+tlv8\r\n\r\n\x00\x01\x00\x06\x01\x01" # pylint: disable=line-too-long + ) + assert writer.call_args_list[0][0][0].startswith(b"HTTP/1.1 200 OK\r\n") is True + hap_proto.check_idle(time.time()) + assert hap_proto_close.called is True + + +@pytest.mark.asyncio +async def test_does_not_timeout(driver): + """Test we do not timeout the connection if we have not reached the idle.""" + loop = asyncio.get_event_loop() + transport = MagicMock() + connections = {} + driver.add_accessory(Accessory(driver, "TestAcc")) + + hap_proto = hap_protocol.HAPServerProtocol(loop, connections, driver) + hap_proto.connection_made(transport) + + with patch.object(hap_proto, "close") as hap_proto_close, patch.object( + hap_proto.transport, "write" + ) as writer: + hap_proto.data_received( + b"POST /pair-setup HTTP/1.1\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\nContent-Length: 6\r\nContent-Type: application/pairing+tlv8\r\n\r\n\x00\x01\x00\x06\x01\x01" # pylint: disable=line-too-long + ) + assert writer.call_args_list[0][0][0].startswith(b"HTTP/1.1 200 OK\r\n") is True + hap_proto.check_idle(time.time()) + assert hap_proto_close.called is False diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index 2620fda2..9b469859 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -1,11 +1,12 @@ """Tests for the HAPServer.""" import asyncio -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from pyhap import hap_server +from pyhap.accessory_driver import AccessoryDriver @pytest.mark.asyncio @@ -23,6 +24,33 @@ async def test_we_can_start_stop(driver): server.async_stop() +@pytest.mark.asyncio +async def test_idle_connection_cleanup(): + """Test we cleanup idle connections.""" + loop = asyncio.get_event_loop() + addr_info = ("0.0.0.0", None) + client_1_addr_info = ("1.2.3.4", 44433) + + with patch.object(hap_server, "IDLE_CONNECTION_CHECK_INTERVAL_SECONDS", 0), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( + "pyhap.accessory_driver.AccessoryDriver.load" + ): + driver = AccessoryDriver(loop=loop) + server = hap_server.HAPServer(addr_info, driver) + await server.async_start(loop) + check_idle = MagicMock() + server.connections[client_1_addr_info] = MagicMock(check_idle=check_idle) + for _ in range(3): + await asyncio.sleep(0) + assert check_idle.called + check_idle.reset_mock() + for _ in range(3): + await asyncio.sleep(0) + assert check_idle.called + server.async_stop() + + def test_push_event(driver): """Test we can create and send an event.""" addr_info = ("1.2.3.4", 1234) From 044062d7709c409b8820fde2e1bebae576a852cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 09:27:55 -1000 Subject: [PATCH 09/13] Add bandit to CI and address reported issues (#329) --- .github/workflows/ci.yaml | 20 ++++++++++++++ pyhap/hap_handler.py | 14 +++++----- tests/test_hap_handler.py | 55 +++++++++++++++++++++++++++++++++++++++ tox.ini | 14 +++++++++- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61139072..ac31d615 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,3 +60,23 @@ jobs: fail_ci_if_error: true path_to_write_report: ./coverage/codecov_report.txt verbose: true + + bandit: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.9] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Bandit + run: TOXENV=bandit tox diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 04c79e36..02977d06 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -218,7 +218,6 @@ def dispatch(self, request, body=None): ) path = urlparse(self.path).path - assert path in self.HANDLERS[self.command] try: getattr(self, self.HANDLERS[self.command][path])() except UnprivilegedRequestException: @@ -340,10 +339,13 @@ def _pairing_three(self, tlv_objects): ) cipher = ChaCha20Poly1305(hkdf_enc_key) - decrypted_data = cipher.decrypt( - self.PAIRING_3_NONCE, bytes(encrypted_data), b"" - ) - assert decrypted_data is not None + try: + decrypted_data = cipher.decrypt( + self.PAIRING_3_NONCE, bytes(encrypted_data), b"" + ) + except InvalidTag: + self._send_authentication_error_tlv_response(HAP_TLV_STATES.M6) + return dec_tlv_objects = tlv.decode(bytes(decrypted_data)) client_username = dec_tlv_objects[HAP_TLV_TAGS.USERNAME] @@ -516,8 +518,6 @@ def _pair_verify_two(self, tlv_objects): self._send_authentication_error_tlv_response(HAP_TLV_STATES.M4) return - assert decrypted_data is not None # TODO: - dec_tlv_objects = tlv.decode(bytes(decrypted_data)) client_username = dec_tlv_objects[HAP_TLV_TAGS.USERNAME] material = ( diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 217fd9e4..7cd095cf 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -419,3 +419,58 @@ def test_handle_get_characteristics_encrypted(driver): assert response.status_code == 207 assert b"-70402" in response.body + + +def test_invalid_pairing_two(driver): + """Verify we respond with error with invalid request.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M3, + hap_handler.HAP_TLV_TAGS.ENCRYPTED_DATA, + b"", + hap_handler.HAP_TLV_TAGS.PUBLIC_KEY, + b"", + hap_handler.HAP_TLV_TAGS.PASSWORD_PROOF, + b"", + ) + handler.accessory_handler.setup_srp_verifier() + handler.handle_pairing() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M4, + hap_handler.HAP_TLV_TAGS.ERROR_CODE: hap_handler.HAP_TLV_ERRORS.AUTHENTICATION, + } + + +def test_invalid_pairing_three(driver): + """Verify we respond with error with invalid request.""" + driver.add_accessory(Accessory(driver, "TestAcc")) + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = False + response = hap_handler.HAPResponse() + handler.response = response + handler.request_body = tlv.encode( + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM, + hap_handler.HAP_TLV_STATES.M5, + hap_handler.HAP_TLV_TAGS.ENCRYPTED_DATA, + b"", + ) + handler.accessory_handler.setup_srp_verifier() + handler.accessory_handler.srp_verifier.set_A(b"") + handler.handle_pairing() + + tlv_objects = tlv.decode(response.body) + + assert tlv_objects == { + hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M6, + hap_handler.HAP_TLV_TAGS.ERROR_CODE: hap_handler.HAP_TLV_ERRORS.AUTHENTICATION, + } diff --git a/tox.ini b/tox.ini index 4b613c26..4e30cbe2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, py37, py38, py39, py310, docs, lint, pylint +envlist = py35, py36, py37, py38, py39, py310, docs, lint, pylint, bandit skip_missing_interpreters = True [gh-actions] @@ -62,6 +62,18 @@ commands = pylint tests --disable=duplicate-code,missing-docstring,empty-docstring,invalid-name,fixme --max-line-length=120 +[testenv:bandit] +basepython = {env:PYTHON3_PATH:python3} +ignore_errors = True +deps = + -r{toxinidir}/requirements_all.txt + -r{toxinidir}/requirements_test.txt + bandit +commands = + bandit -r pyhap + + + [testenv:doc-errors] basepython = {env:PYTHON3_PATH:python3} ignore_errors = True From 2a85f1788b43f5fc0b4de1fbcbd2e758748058de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 20:01:24 -1000 Subject: [PATCH 10/13] Complete coverage for tlv (#330) --- pyhap/tlv.py | 11 +++++------ tests/test_tlv.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 tests/test_tlv.py diff --git a/pyhap/tlv.py b/pyhap/tlv.py index 7f1df931..6847b611 100644 --- a/pyhap/tlv.py +++ b/pyhap/tlv.py @@ -18,7 +18,7 @@ def encode(*args, to_base64=False): :rtype: ``bytes`` if ``toBase64`` is False and ``str`` otherwise. """ if len(args) % 2 != 0: - raise ValueError('Even number of args expected (%d given)' % len(args)) + raise ValueError("Even number of args expected (%d given)" % len(args)) pieces = [] for x in range(0, len(args), 2): @@ -30,10 +30,9 @@ def encode(*args, to_base64=False): else: encoded = b"" for y in range(0, total_length // 255): - encoded = encoded + tag + b'\xFF' + data[y * 255: (y + 1) * 255] + encoded = encoded + tag + b"\xFF" + data[y * 255 : (y + 1) * 255] remaining = total_length % 255 - encoded = encoded + tag + struct.pack("B", remaining) \ - + data[-remaining:] + encoded = encoded + tag + struct.pack("B", remaining) + data[-remaining:] pieces.append(encoded) @@ -59,9 +58,9 @@ def decode(data, from_base64=False): while current < len(data): # The following hack is because bytes[x] is an int # and we want to keep the tag as a byte. - tag = data[current: current + 1] + tag = data[current : current + 1] length = data[current + 1] - value = data[current + 2: current + 2 + length] + value = data[current + 2 : current + 2 + length] if tag in objects: objects[tag] = objects[tag] + value else: diff --git a/tests/test_tlv.py b/tests/test_tlv.py new file mode 100644 index 00000000..28260c60 --- /dev/null +++ b/tests/test_tlv.py @@ -0,0 +1,28 @@ +"""Tests for pyhap.tlv.""" + +import pytest +from pyhap import tlv + + +def test_tlv_round_trip(): + """Test tlv can round trip TLV8 data.""" + message = tlv.encode( + b"\x01", + b"A", + b"\x01", + b"B", + b"\x02", + b"C", + ) + + decoded = tlv.decode(message) + assert decoded == { + b"\x01": b"AB", + b"\x02": b"C", + } + + +def test_tlv_invalid_pairs(): + """Test we encode fails with an odd amount of args.""" + with pytest.raises(ValueError): + tlv.encode(b"\x01", b"A", b"\02") From 87e9f4d5d3db142f671b30e9553b8a3940cacc33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 20:01:49 -1000 Subject: [PATCH 11/13] Increase coverage for hap_server (#331) --- pyhap/hap_server.py | 7 +++---- tests/test_hap_server.py | 26 +++++++++++++++++++++++++- tests/test_hsrp.py | 1 + 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index e9f44b24..ada7fa4f 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -84,12 +84,11 @@ def async_stop(self): This method must be run in the event loop. """ + self._connection_cleanup.cancel() + for hap_proto in list(self.connections.values()): + hap_proto.close() self.server.close() - for hap_server_protocol in list(self.connections.values()): - if hap_server_protocol: - hap_server_protocol.close() self.connections.clear() - self._connection_cleanup.cancel() def push_event(self, bytesdata, client_addr): """Send an event to the current connection with the provided data. diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index 9b469859..8941878a 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -6,6 +6,7 @@ import pytest from pyhap import hap_server +from pyhap.accessory import Accessory from pyhap.accessory_driver import AccessoryDriver @@ -20,10 +21,33 @@ async def test_we_can_start_stop(driver): server = hap_server.HAPServer(addr_info, driver) await server.async_start(loop) server.connections[client_1_addr_info] = MagicMock() - server.connections[client_2_addr_info] = None + server.connections[client_2_addr_info] = MagicMock() server.async_stop() +@pytest.mark.asyncio +async def test_we_can_connect(): + """Test we can start, connect, and stop.""" + loop = asyncio.get_event_loop() + with patch("pyhap.accessory_driver.Zeroconf"), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + driver = AccessoryDriver(loop=loop) + + driver.add_accessory(Accessory(driver, "TestAcc")) + + addr_info = ("0.0.0.0", None) + server = hap_server.HAPServer(addr_info, driver) + await server.async_start(loop) + sock = server.server.sockets[0] + assert server.connections == {} + _, port = sock.getsockname() + _, writer = await asyncio.open_connection("127.0.0.1", port) + assert server.connections != {} + server.async_stop() + writer.close() + + @pytest.mark.asyncio async def test_idle_connection_cleanup(): """Test we cleanup idle connections.""" diff --git a/tests/test_hsrp.py b/tests/test_hsrp.py index faec9cc0..24b93496 100644 --- a/tests/test_hsrp.py +++ b/tests/test_hsrp.py @@ -3,6 +3,7 @@ # pylint: disable=line-too-long, pointless-string-statement import hashlib + from pyhap.hsrp import Server from pyhap.params import get_srp_context from pyhap.util import long_to_bytes From 220a5e7e092e54cfa1bdaeea93751e601d628383 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Mar 2021 21:17:24 -1000 Subject: [PATCH 12/13] Complete hap_protocol coverage (#332) --- pyhap/hap_protocol.py | 27 +++++++++++++------- tests/test_hap_protocol.py | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 9c4d2760..8a0a954c 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -106,6 +106,12 @@ def send_response(self, response: HAPResponse) -> None: + self.conn.send(h11.Data(data=response.body)) + self.conn.send(h11.EndOfMessage()) ) + self.transport.resume_reading() + + def finish_and_close(self): + """Cleanly finish and close the connection.""" + self.conn.send(h11.ConnectionClosed()) + self.close() def check_idle(self, now) -> None: """Abort when do not get any data within the timeout.""" @@ -141,19 +147,18 @@ def data_received(self, data: bytes) -> None: self.conn.receive_data(data) logger.debug("%s: Recv unencrypted: %s", self.peername, data) - while self._process_one_event(): - pass + try: + while self._process_one_event(): + if self.conn.our_state is h11.MUST_CLOSE: + self.finish_and_close() + except h11.ProtocolError as protocol_ex: + self._handle_invalid_conn_state(protocol_ex) def _process_one_event(self) -> bool: """Process one http event.""" - if self.conn.our_state is h11.MUST_CLOSE: - return self._handle_invalid_conn_state("connection state is must close") - event = self.conn.next_event() - logger.debug("%s: h11 Event: %s", self.peername, event) - - if event is h11.NEED_DATA: + if event in (h11.NEED_DATA, h11.ConnectionClosed): return False if event is h11.PAUSED: @@ -170,6 +175,7 @@ def _process_one_event(self) -> bool: return True if isinstance(event, h11.EndOfMessage): + self.transport.pause_reading() response = self.handler.dispatch(self.request, bytes(self.request_body)) self._process_response(response) self.request = None @@ -203,7 +209,10 @@ def _handle_response_ready(self, task: asyncio.Task) -> None: self.response = None try: response.body = task.result() - except Exception: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except + logger.debug( + "%s: exception during delayed response", self.peername, exc_info=ex + ) response = self.handler.generic_failure_response() self.send_response(response) diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index a7d4970b..4e6d972e 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -118,6 +118,57 @@ def test_http10_close(driver): hap_proto.close() +def test_invalid_content_length(driver): + """Test we handle invalid content length.""" + loop = MagicMock() + transport = MagicMock() + connections = {} + driver.add_accessory(Accessory(driver, "TestAcc")) + + hap_proto = hap_protocol.HAPServerProtocol(loop, connections, driver) + hap_proto.connection_made(transport) + + with patch.object(hap_proto.transport, "write") as writer: + hap_proto.data_received( + b"POST /pair-setup HTTP/1.0\r\nConnection:close\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\nContent-Length: 2\r\nContent-Type: application/pairing+tlv8\r\n\r\n\x00\x01\x00\x06\x01\x01" # pylint: disable=line-too-long + ) + hap_proto.data_received( + b"POST /pair-setup HTTP/1.0\r\nConnection:close\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\nContent-Length: 2\r\nContent-Type: application/pairing+tlv8\r\n\r\n\x00\x01\x00\x06\x01\x01" # pylint: disable=line-too-long + ) + + assert ( + writer.call_args_list[0][0][0].startswith( + b"HTTP/1.1 500 Internal Server Error\r\n" + ) + is True + ) + assert len(writer.call_args_list) == 1 + assert connections == {} + hap_proto.close() + + +def test_invalid_client_closes_connection(driver): + """Test we handle client closing the connection.""" + loop = MagicMock() + transport = MagicMock() + connections = {} + driver.add_accessory(Accessory(driver, "TestAcc")) + + hap_proto = hap_protocol.HAPServerProtocol(loop, connections, driver) + hap_proto.connection_made(transport) + + with patch.object(hap_proto.transport, "write") as writer: + hap_proto.data_received( + b"POST /pair-setup HTTP/1.0\r\nConnection:close\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\nContent-Length: 6\r\nContent-Type: application/pairing+tlv8\r\n\r\n\x00\x01\x00\x06\x01\x01" # pylint: disable=line-too-long + ) + hap_proto.data_received(b"") + + assert writer.call_args_list[0][0][0].startswith(b"HTTP/1.1 200 OK\r\n") is True + assert len(writer.call_args_list) == 1 + assert connections == {} + hap_proto.close() + + def test_pair_setup_split_between_packets(driver): """Verify an non-encrypt request.""" loop = MagicMock() From 53b0f6efc6ff59a7d63ea3be30c9040a563b571d Mon Sep 17 00:00:00 2001 From: Ivan Kalchev Date: Sat, 6 Mar 2021 09:49:47 +0200 Subject: [PATCH 13/13] v3.4.0 --- CHANGELOG.md | 19 +++++++++++++++++++ pyhap/const.py | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7fb141..9988a977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,25 @@ Sections ### Developers --> +## [3.4.0] - 2021-03-06 + +### Added +- Python 3.10 support. [#328](https://github.com/ikalchev/HAP-python/pull/328) + +### Fixed +- Improve connection stability with large responses. [#320](https://github.com/ikalchev/HAP-python/pull/320) +- Fix `Accessroy.run` not being awaited from a bridge. [#323](https://github.com/ikalchev/HAP-python/pull/323) +- Clean up event subscriptions on client disconnect. [#324](https://github.com/ikalchev/HAP-python/pull/324) + +### Removed +- Remove legacy python code. [#321](https://github.com/ikalchev/HAP-python/pull/321) +- Remove deprecated `get_char_loader` and `get_serv_loader`. [#322](https://github.com/ikalchev/HAP-python/pull/322) + +### Developers +- Increase code coverage. [#325](https://github.com/ikalchev/HAP-python/pull/325), [#326](https://github.com/ikalchev/HAP-python/pull/326), [#330](https://github.com/ikalchev/HAP-python/pull/330), [#331](https://github.com/ikalchev/HAP-python/pull/331), [#332](https://github.com/ikalchev/HAP-python/pull/332) +- Add bandit to CI. [#329](https://github.com/ikalchev/HAP-python/pull/329) + + ## [3.3.2] - 2021-03-01 ### Fixed diff --git a/pyhap/const.py b/pyhap/const.py index 008fb0ea..473165cc 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,7 +1,7 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 3 -MINOR_VERSION = 3 -PATCH_VERSION = 2 +MINOR_VERSION = 4 +PATCH_VERSION = 0 __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5)