From 7823c032636b5f32c6f6aed227b50b94df705b15 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 27 Nov 2023 15:13:18 +0200 Subject: [PATCH 01/13] Late-import base36 and QR code libraries; remove SUPPORT_QR_CODE flag --- pyhap/__init__.py | 12 ------------ pyhap/accessory.py | 31 ++++++++++++++++--------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/pyhap/__init__.py b/pyhap/__init__.py index abf37c01..e8b2845c 100644 --- a/pyhap/__init__.py +++ b/pyhap/__init__.py @@ -5,15 +5,3 @@ CHARACTERISTICS_FILE = os.path.join(RESOURCE_DIR, "characteristics.json") SERVICES_FILE = os.path.join(RESOURCE_DIR, "services.json") - - -# Flag if QR Code dependencies are installed. -# Installation with `pip install HAP-python[QRCode]`. -SUPPORT_QR_CODE = False -try: - import base36 # noqa: F401 - import pyqrcode # noqa: F401 - - SUPPORT_QR_CODE = True -except ImportError: - pass diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 902fc7f7..d67c7c2d 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional from uuid import UUID -from . import SUPPORT_QR_CODE, util +from . import util from .const import ( CATEGORY_BRIDGE, CATEGORY_OTHER, @@ -18,10 +18,6 @@ from .iid_manager import IIDManager from .service import Service -if SUPPORT_QR_CODE: - import base36 - from pyqrcode import QRCode - if TYPE_CHECKING: from .accessory_driver import AccessoryDriver @@ -190,6 +186,12 @@ def xhm_uri(self) -> str: :rtype: str """ + try: + import base36 + except ImportError as ie: + raise RuntimeError( + "The base36 module is required to generate X-HM:// URIs" + ) from ie payload = 0 payload |= 0 & 0x7 # version @@ -253,7 +255,15 @@ def setup_message(self): Installation through `pip install HAP-python[QRCode]` """ pincode = self.driver.state.pincode.decode() - if SUPPORT_QR_CODE: + try: + from qrcode import QRCode + except ImportError: + print( + "To use the QR Code feature, use 'pip install HAP-python[QRCode]'\n" + f"Enter this code in your HomeKit app on your iOS device: {pincode}", + flush=True, + ) + else: xhm_uri = self.xhm_uri() print(f"Setup payload: {xhm_uri}", flush=True) print( @@ -264,15 +274,6 @@ def setup_message(self): f"Or enter this code in your HomeKit app on your iOS device: {pincode}", flush=True, ) - else: - print( - "To use the QR Code feature, use 'pip install HAP-python[QRCode]'", - flush=True, - ) - print( - f"Enter this code in your HomeKit app on your iOS device: {pincode}", - flush=True, - ) @staticmethod def run_at_interval(seconds): From 7cb69d93e19a8e87bbf1ce131ba7c5a24f63fc4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 01:39:45 -0600 Subject: [PATCH 02/13] Increase idle connection check interval to 300s (#475) This check was creating a lot of TimerHandles when the user had multiple bridges. We do not need to check very often as connections usually stay around for 24+hours --- pyhap/hap_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index 1e8414a8..d4265a8e 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -IDLE_CONNECTION_CHECK_INTERVAL_SECONDS = 120 +IDLE_CONNECTION_CHECK_INTERVAL_SECONDS = 300 class HAPServer: From 20f51518a8a7755ab5e1e3564d03054b9f40b089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 01:44:39 -0600 Subject: [PATCH 03/13] Implement zerocopy writes for the encrypted protocol (#476) * Implement zerocopy writes for the encrypted protocol With Python 3.12+ and later `transport.writelines` is implemented as [`sendmsg(..., IOV_MAX)`](https://github.com/python/cpython/issues/91166) which allows us to avoid joining the bytes and sending them in one go. Older Python will effectively do the same thing we do now `b"".join(...)` * update tests --- pyhap/hap_crypto.py | 9 ++--- pyhap/hap_protocol.py | 2 +- tests/test_hap_crypto.py | 2 +- tests/test_hap_protocol.py | 78 ++++++++++++++++++++------------------ 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/pyhap/hap_crypto.py b/pyhap/hap_crypto.py index 3299bd85..6315f475 100644 --- a/pyhap/hap_crypto.py +++ b/pyhap/hap_crypto.py @@ -3,7 +3,7 @@ import logging import struct from struct import Struct -from typing import List +from typing import Iterable, List from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 from cryptography.hazmat.backends import default_backend @@ -112,7 +112,7 @@ def decrypt(self) -> bytes: return result - def encrypt(self, data: bytes) -> bytes: + def encrypt(self, data: bytes) -> Iterable[bytes]: """Encrypt and send the return bytes.""" result: List[bytes] = [] offset = 0 @@ -127,7 +127,4 @@ def encrypt(self, data: bytes) -> bytes: offset += length self._out_count += 1 - # Join the result once instead of concatenating each time - # as this is much faster than generating an new immutable - # byte string each time. - return b"".join(result) + return result diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 0f51dec9..75d18355 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -104,7 +104,7 @@ def write(self, data: bytes) -> None: self.handler.client_uuid, data, ) - self.transport.write(result) + self.transport.writelines(result) else: logger.debug( "%s (%s): Send unencrypted: %s", diff --git a/tests/test_hap_crypto.py b/tests/test_hap_crypto.py index 397d721b..c6404656 100644 --- a/tests/test_hap_crypto.py +++ b/tests/test_hap_crypto.py @@ -15,7 +15,7 @@ def test_round_trip(): crypto.OUT_CIPHER_INFO = crypto.IN_CIPHER_INFO crypto.reset(key) - encrypted = bytearray(crypto.encrypt(plaintext)) + encrypted = bytearray(b"".join(crypto.encrypt(plaintext))) # Receive no data assert crypto.decrypt() == b"" diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 06bd71b9..ca121083 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -246,13 +246,13 @@ def test_get_accessories_with_crypto(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b"GET /accessories HTTP/1.1\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\n\r\n" # pylint: disable=line-too-long ) hap_proto.close() - assert b"accessories" in writer.call_args_list[0][0][0] + assert b"accessories" in b"".join(writelines.call_args_list[0][0]) def test_get_characteristics_with_crypto(driver): @@ -273,7 +273,7 @@ def test_get_characteristics_with_crypto(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b"GET /characteristics?id=3762173001.7 HTTP/1.1\r\nHost: HASS\\032Bridge\\032YPHW\\032B223AD._hap._tcp.local\r\n\r\n" # pylint: disable=line-too-long ) @@ -282,13 +282,15 @@ def test_get_characteristics_with_crypto(driver): ) hap_proto.close() - assert b"Content-Length:" in writer.call_args_list[0][0][0] - assert b"Transfer-Encoding: chunked\r\n\r\n" not in writer.call_args_list[0][0][0] - assert b"-70402" in writer.call_args_list[0][0][0] + joined0 = b"".join(writelines.call_args_list[0][0]) + assert b"Content-Length:" in joined0 + assert b"Transfer-Encoding: chunked\r\n\r\n" not in joined0 + assert b"-70402" in joined0 - assert b"Content-Length:" in writer.call_args_list[1][0][0] - assert b"Transfer-Encoding: chunked\r\n\r\n" not in writer.call_args_list[1][0][0] - assert b"TestAcc" in writer.call_args_list[1][0][0] + joined1 = b"".join(writelines.call_args_list[1][0]) + assert b"Content-Length:" in joined1 + assert b"Transfer-Encoding: chunked\r\n\r\n" not in joined1 + assert b"TestAcc" in joined1 def test_set_characteristics_with_crypto(driver): @@ -309,13 +311,15 @@ def test_set_characteristics_with_crypto(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'PUT /characteristics HTTP/1.1\r\nHost: HASS12\\032AD1C22._hap._tcp.local\r\nContent-Length: 49\r\nContent-Type: application/hap+json\r\n\r\n{"characteristics":[{"aid":1,"iid":9,"ev":true}]}' # pylint: disable=line-too-long ) hap_proto.close() - assert writer.call_args_list[0][0][0] == b"HTTP/1.1 204 No Content\r\n\r\n" + assert ( + b"".join(writelines.call_args_list[0][0]) == b"HTTP/1.1 204 No Content\r\n\r\n" + ) def test_crypto_failure_closes_connection(driver): @@ -352,14 +356,14 @@ def test_empty_encrypted_data(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received(b"") hap_proto.data_received( b"GET /accessories HTTP/1.1\r\nHost: Bridge\\032C77C47._hap._tcp.local\r\n\r\n" # pylint: disable=line-too-long ) hap_proto.close() - assert b"accessories" in writer.call_args_list[0][0][0] + assert b"accessories" in b"".join(writelines.call_args_list[0][0]) def test_http_11_keep_alive(driver): @@ -434,13 +438,13 @@ def test_camera_snapshot_without_snapshot_support(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) hap_proto.close() - assert b"-70402" in writer.call_args_list[0][0][0] + assert b"-70402" in b"".join(writelines.call_args_list[0][0]) @pytest.mark.asyncio @@ -464,14 +468,14 @@ def _get_snapshot(*_): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) await hap_proto.response.task await asyncio.sleep(0) - assert b"fakesnap" in writer.call_args_list[0][0][0] + assert b"fakesnap" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -497,14 +501,14 @@ async def _async_get_snapshot(*_): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) await hap_proto.response.task await asyncio.sleep(0) - assert b"fakesnap" in writer.call_args_list[0][0][0] + assert b"fakesnap" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -532,14 +536,14 @@ async def _async_get_snapshot(*_): hap_proto.handler.is_encrypted = True with patch.object(hap_handler, "RESPONSE_TIMEOUT", 0.1), patch.object( - hap_proto.transport, "write" - ) as writer: + hap_proto.transport, "writelines" + ) as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) await asyncio.sleep(0.3) - assert b"-70402" in writer.call_args_list[0][0][0] + assert b"-70402" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -564,7 +568,7 @@ def _make_response(*_): response.shared_key = b"newkey" return response - with patch.object(hap_proto.transport, "write"), patch.object( + with patch.object(hap_proto.transport, "writelines"), patch.object( hap_proto.handler, "dispatch", _make_response ): hap_proto.data_received( @@ -635,7 +639,7 @@ async def _async_get_snapshot(*_): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) @@ -645,7 +649,7 @@ async def _async_get_snapshot(*_): pass await asyncio.sleep(0) - assert b"-70402" in writer.call_args_list[0][0][0] + assert b"-70402" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -671,7 +675,7 @@ def _get_snapshot(*_): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) @@ -681,7 +685,7 @@ def _get_snapshot(*_): pass await asyncio.sleep(0) - assert b"-70402" in writer.call_args_list[0][0][0] + assert b"-70402" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -702,14 +706,14 @@ async def test_camera_snapshot_missing_accessory(driver): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) await asyncio.sleep(0) assert hap_proto.response is None - assert b"-70402" in writer.call_args_list[0][0][0] + assert b"-70402" in b"".join(writelines.call_args_list[0][0]) hap_proto.close() @@ -777,7 +781,7 @@ def test_explicit_close(driver: AccessoryDriver): hap_proto.handler.is_encrypted = True assert hap_proto.transport.is_closing() is False - with patch.object(hap_proto.transport, "write") as writer: + with patch.object(hap_proto.transport, "writelines") as writelines: hap_proto.data_received( b"GET /characteristics?id=3762173001.7 HTTP/1.1\r\nHost: HASS\\032Bridge\\032YPHW\\032B223AD._hap._tcp.local\r\n\r\n" # pylint: disable=line-too-long ) @@ -785,12 +789,14 @@ def test_explicit_close(driver: AccessoryDriver): b"GET /characteristics?id=1.5 HTTP/1.1\r\nConnection: close\r\nHost: HASS\\032Bridge\\032YPHW\\032B223AD._hap._tcp.local\r\n\r\n" # pylint: disable=line-too-long ) - assert b"Content-Length:" in writer.call_args_list[0][0][0] - assert b"Transfer-Encoding: chunked\r\n\r\n" not in writer.call_args_list[0][0][0] - assert b"-70402" in writer.call_args_list[0][0][0] + join0 = b"".join(writelines.call_args_list[0][0]) + assert b"Content-Length:" in join0 + assert b"Transfer-Encoding: chunked\r\n\r\n" not in join0 + assert b"-70402" in join0 - assert b"Content-Length:" in writer.call_args_list[1][0][0] - assert b"Transfer-Encoding: chunked\r\n\r\n" not in writer.call_args_list[1][0][0] - assert b"TestAcc" in writer.call_args_list[1][0][0] + join1 = b"".join(writelines.call_args_list[1][0]) + assert b"Content-Length:" in join1 + assert b"Transfer-Encoding: chunked\r\n\r\n" not in join1 + assert b"TestAcc" in join1 assert hap_proto.transport.is_closing() is True From 1feefd89b6a4e240e81ea42833eb236ebee99cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 01:45:27 -0600 Subject: [PATCH 04/13] Revert "Late-import base36 and QR code libraries; remove SUPPORT_QR_CODE flag" (#477) --- pyhap/__init__.py | 12 ++++++++++++ pyhap/accessory.py | 31 +++++++++++++++---------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/pyhap/__init__.py b/pyhap/__init__.py index e8b2845c..abf37c01 100644 --- a/pyhap/__init__.py +++ b/pyhap/__init__.py @@ -5,3 +5,15 @@ CHARACTERISTICS_FILE = os.path.join(RESOURCE_DIR, "characteristics.json") SERVICES_FILE = os.path.join(RESOURCE_DIR, "services.json") + + +# Flag if QR Code dependencies are installed. +# Installation with `pip install HAP-python[QRCode]`. +SUPPORT_QR_CODE = False +try: + import base36 # noqa: F401 + import pyqrcode # noqa: F401 + + SUPPORT_QR_CODE = True +except ImportError: + pass diff --git a/pyhap/accessory.py b/pyhap/accessory.py index d67c7c2d..902fc7f7 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional from uuid import UUID -from . import util +from . import SUPPORT_QR_CODE, util from .const import ( CATEGORY_BRIDGE, CATEGORY_OTHER, @@ -18,6 +18,10 @@ from .iid_manager import IIDManager from .service import Service +if SUPPORT_QR_CODE: + import base36 + from pyqrcode import QRCode + if TYPE_CHECKING: from .accessory_driver import AccessoryDriver @@ -186,12 +190,6 @@ def xhm_uri(self) -> str: :rtype: str """ - try: - import base36 - except ImportError as ie: - raise RuntimeError( - "The base36 module is required to generate X-HM:// URIs" - ) from ie payload = 0 payload |= 0 & 0x7 # version @@ -255,15 +253,7 @@ def setup_message(self): Installation through `pip install HAP-python[QRCode]` """ pincode = self.driver.state.pincode.decode() - try: - from qrcode import QRCode - except ImportError: - print( - "To use the QR Code feature, use 'pip install HAP-python[QRCode]'\n" - f"Enter this code in your HomeKit app on your iOS device: {pincode}", - flush=True, - ) - else: + if SUPPORT_QR_CODE: xhm_uri = self.xhm_uri() print(f"Setup payload: {xhm_uri}", flush=True) print( @@ -274,6 +264,15 @@ def setup_message(self): f"Or enter this code in your HomeKit app on your iOS device: {pincode}", flush=True, ) + else: + print( + "To use the QR Code feature, use 'pip install HAP-python[QRCode]'", + flush=True, + ) + print( + f"Enter this code in your HomeKit app on your iOS device: {pincode}", + flush=True, + ) @staticmethod def run_at_interval(seconds): From 1042ae5f33370c9fa09499f535506451b3621d60 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 3 Nov 2024 00:52:04 -0700 Subject: [PATCH 05/13] Avoid os.chmod failing on Windows if file non-existant (#471) * Avoid os.chmod failing on Windows if file non-existant * Update accessory_driver.py --------- Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com> --- pyhap/accessory_driver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 3cc160fc..06224c8b 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -642,16 +642,18 @@ def persist(self): tmp_filename = None try: temp_dir = os.path.dirname(self.persist_file) + logger.debug("Creating temp persist file in '%s'", temp_dir) with tempfile.NamedTemporaryFile( mode="w", dir=temp_dir, delete=False ) as file_handle: tmp_filename = file_handle.name + logger.debug("Created temp persist file '%s' named '%s'", file_handle, tmp_filename) self.encoder.persist(file_handle, self.state) if ( os.name == "nt" ): # Or `[WinError 5] Access Denied` will be raised on Windows os.chmod(tmp_filename, 0o644) - os.chmod(self.persist_file, 0o644) + os.path.exists(self.persist_file) and os.chmod(self.persist_file, 0o644) os.replace(tmp_filename, self.persist_file) except Exception: # pylint: disable=broad-except logger.exception("Failed to persist accessory state") From 1a16125367e8cb1dee9ddab79826adeea358f0d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 01:55:14 -0600 Subject: [PATCH 06/13] Fix mdns tests (#478) --- tests/test_accessory_driver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index e7234fb1..88061ed1 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -961,7 +961,7 @@ def test_mdns_service_info(driver: AccessoryDriver): assert mdns_info.server == "Test-Accessory-000000.local." assert mdns_info.port == port assert mdns_info.addresses == [b"\xac\x00\x00\x01"] - assert mdns_info.properties == { + assert mdns_info.decoded_properties == { "md": "Test Accessory", "pv": "1.1", "id": "00:00:00:00:00:00", @@ -990,7 +990,7 @@ def test_mdns_service_info_with_specified_server(driver: AccessoryDriver): assert mdns_info.server == "hap1.local." assert mdns_info.port == port assert mdns_info.addresses == [b"\xac\x00\x00\x01"] - assert mdns_info.properties == { + assert mdns_info.decoded_properties == { "md": "Test Accessory", "pv": "1.1", "id": "00:00:00:00:00:00", From c419ac79898569dd40e6466a041e07fe66c27fec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 01:55:33 -0600 Subject: [PATCH 07/13] Fix pylint complaints (#480) --- pyhap/accessory.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 902fc7f7..89ecda8b 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -207,7 +207,7 @@ def xhm_uri(self) -> str: int(self.driver.state.pincode.replace(b"-", b""), 10) & 0x7FFFFFFF ) # pincode - encoded_payload = base36.dumps(payload).upper() + encoded_payload = base36.dumps(payload).upper() # pylint: disable=possibly-used-before-assignment encoded_payload = encoded_payload.rjust(9, "0") return "X-HM://" + encoded_payload + self.driver.state.setup_id diff --git a/pyproject.toml b/pyproject.toml index 9d568bbe..4c31e9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ disable = [ "too-many-return-statements", "too-many-statements", "too-many-boolean-expressions", + "too-many-positional-arguments", "unused-argument", "wrong-import-order", "unused-argument", From 0a97ad989bbac12fb5a492436801c3fb8369b888 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 02:11:19 -0600 Subject: [PATCH 08/13] Address remaining pylint complaints (#481) * Address remaining pylint complaints * Address remaining pylint complaints --- pyhap/accessory_driver.py | 5 +++-- tox.ini | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 06224c8b..b2b2f4bb 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -323,7 +323,7 @@ def start(self): and os.name != "nt" ): logger.debug("Setting child watcher") - watcher = asyncio.SafeChildWatcher() + watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class watcher.attach_loop(self.loop) asyncio.set_child_watcher(watcher) else: @@ -653,7 +653,8 @@ def persist(self): os.name == "nt" ): # Or `[WinError 5] Access Denied` will be raised on Windows os.chmod(tmp_filename, 0o644) - os.path.exists(self.persist_file) and os.chmod(self.persist_file, 0o644) + if os.path.exists(self.persist_file): + os.chmod(self.persist_file, 0o644) os.replace(tmp_filename, self.persist_file) except Exception: # pylint: disable=broad-except logger.exception("Failed to persist accessory state") diff --git a/tox.ini b/tox.ini index 4e34d4a9..c8ef3f22 100644 --- a/tox.ini +++ b/tox.ini @@ -61,8 +61,8 @@ deps = -r{toxinidir}/requirements_all.txt -r{toxinidir}/requirements_test.txt commands = - pylint pyhap --disable=missing-docstring,empty-docstring,invalid-name,fixme --max-line-length=120 - pylint tests --disable=duplicate-code,missing-docstring,empty-docstring,invalid-name,fixme --max-line-length=120 + pylint pyhap --disable=missing-docstring,empty-docstring,invalid-name,fixme,too-many-positional-arguments --max-line-length=120 + pylint tests --disable=duplicate-code,missing-docstring,empty-docstring,invalid-name,fixme,too-many-positional-arguments --max-line-length=120 [testenv:bandit] From 86ed133f258c770fb348232b1cab4b781139fd95 Mon Sep 17 00:00:00 2001 From: Ivan Kalchev Date: Sun, 3 Nov 2024 10:18:57 +0200 Subject: [PATCH 09/13] v4.9.2 --- CHANGELOG.md | 5 +++++ pyhap/const.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a1945d..1066948f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Sections ### Developers --> +## [4.9.2] - 2024-11-03 + +- Implement zerocopy writes for the encrypted protocol. [#476](https://github.com/ikalchev/HAP-python/pull/476) +- Linter and test fixe. + ## [4.9.1] - 2023-10-25 - Fix handling of explict close. [#467](https://github.com/ikalchev/HAP-python/pull/467) diff --git a/pyhap/const.py b/pyhap/const.py index 9f081eef..148beb4b 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,7 +1,7 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 4 MINOR_VERSION = 9 -PATCH_VERSION = 1 +PATCH_VERSION = 2 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7) From 01af9961de403f63cc6bb8714f19988df9b66325 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 17 Nov 2024 22:18:14 +0100 Subject: [PATCH 10/13] Use SPDX license expression (#484) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ec5c4253..778226ca 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ project_urls=PROJECT_URLS, python_requires=">={}".format(MIN_PY_VERSION), install_requires=REQUIRES, - license="Apache License 2.0", + license="Apache-2.0", license_file="LICENSE", classifiers=[ "Development Status :: 5 - Production/Stable", From 622a19be899ff35df5d84a158614b5bfa6627162 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Aug 2025 20:51:08 +0200 Subject: [PATCH 11/13] Modernize packaging + drop support for 3.7 and 3.8 (#487) * Modernize packaging * Fix tests * Update formatting with black 25.1.0 * Ignore flake8 error * Remove deprecated html_theme_path option * Only require async_timeout for Python<3.11 --- .github/workflows/ci.yaml | 21 +- MANIFEST.in | 3 +- docs/source/conf.py | 2 - pyhap/accessory.py | 4 +- pyhap/accessory_driver.py | 11 +- pyhap/camera.py | 698 ++++++++++++++++++--------------- pyhap/characteristic.py | 1 + pyhap/const.py | 2 +- pyhap/encoder.py | 1 + pyhap/hap_crypto.py | 1 + pyhap/hap_handler.py | 16 +- pyhap/hap_protocol.py | 1 + pyhap/iid_manager.py | 1 + pyhap/loader.py | 1 + pyhap/state.py | 3 +- pyhap/tlv.py | 3 +- pyhap/util.py | 9 +- pyproject.toml | 54 ++- requirements_all.txt | 1 + scripts/release | 12 +- setup.cfg | 3 - setup.py | 68 ---- tests/conftest.py | 12 +- tests/test_accessory.py | 20 +- tests/test_accessory_driver.py | 116 +++--- tests/test_camera.py | 4 +- tests/test_characteristic.py | 1 + tests/test_encoder.py | 1 + tests/test_hap_crypto.py | 1 - tests/test_hap_handler.py | 30 +- tests/test_hap_protocol.py | 44 +-- tests/test_hap_server.py | 21 +- tests/test_iid_manager.py | 1 + tests/test_loader.py | 1 + tests/test_service.py | 30 +- tests/test_state.py | 37 +- tests/test_util.py | 1 + tox.ini | 9 +- 38 files changed, 658 insertions(+), 587 deletions(-) delete mode 100644 setup.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f8ee3dda..fc243e44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,13 +8,14 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -35,14 +36,14 @@ jobs: strategy: matrix: - python-version: ["3.10"] + python-version: ["3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip @@ -66,12 +67,12 @@ jobs: strategy: matrix: - python-version: ["3.10"] + python-version: ["3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/MANIFEST.in b/MANIFEST.in index 350d0849..69576498 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1 @@ -include README.md LICENSE -recursive-include pyhap/resources * \ No newline at end of file +recursive-include pyhap/resources * diff --git a/docs/source/conf.py b/docs/source/conf.py index 0e484bb4..9b725964 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -87,8 +87,6 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -import sphinx_rtd_theme -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 89ecda8b..afbccce4 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -1,4 +1,5 @@ """Module for the Accessory classes.""" + import itertools import logging from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional @@ -207,7 +208,8 @@ def xhm_uri(self) -> str: int(self.driver.state.pincode.replace(b"-", b""), 10) & 0x7FFFFFFF ) # pincode - encoded_payload = base36.dumps(payload).upper() # pylint: disable=possibly-used-before-assignment + # pylint: disable-next=possibly-used-before-assignment + encoded_payload = base36.dumps(payload).upper() encoded_payload = encoded_payload.rjust(9, "0") return "X-HM://" + encoded_payload + self.driver.state.setup_id diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index b2b2f4bb..782f492a 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -15,6 +15,7 @@ terminates the call chain and concludes the publishing process from the Characteristic, the Characteristic does not block waiting for the actual send to happen. """ + import asyncio import base64 from collections import defaultdict @@ -212,7 +213,7 @@ def __init__( advertised_address=None, interface_choice=None, async_zeroconf_instance=None, - zeroconf_server=None + zeroconf_server=None, ): """ Initialize a new AccessoryDriver object. @@ -323,7 +324,7 @@ def start(self): and os.name != "nt" ): logger.debug("Setting child watcher") - watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class + watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class watcher.attach_loop(self.loop) asyncio.set_child_watcher(watcher) else: @@ -647,7 +648,11 @@ def persist(self): mode="w", dir=temp_dir, delete=False ) as file_handle: tmp_filename = file_handle.name - logger.debug("Created temp persist file '%s' named '%s'", file_handle, tmp_filename) + logger.debug( + "Created temp persist file '%s' named '%s'", + file_handle, + tmp_filename, + ) self.encoder.persist(file_handle, self.state) if ( os.name == "nt" diff --git a/pyhap/camera.py b/pyhap/camera.py index a33e7aee..f77de394 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -22,205 +22,209 @@ import logging import os import struct +import sys from uuid import UUID -import async_timeout - from pyhap import RESOURCE_DIR, tlv from pyhap.accessory import Accessory from pyhap.const import CATEGORY_CAMERA from pyhap.util import byte_bool, to_base64_str +if sys.version_info >= (3, 11): + from asyncio import timeout as async_timeout +else: + from async_timeout import timeout as async_timeout + SETUP_TYPES = { - 'SESSION_ID': b'\x01', - 'STATUS': b'\x02', - 'ADDRESS': b'\x03', - 'VIDEO_SRTP_PARAM': b'\x04', - 'AUDIO_SRTP_PARAM': b'\x05', - 'VIDEO_SSRC': b'\x06', - 'AUDIO_SSRC': b'\x07' + "SESSION_ID": b"\x01", + "STATUS": b"\x02", + "ADDRESS": b"\x03", + "VIDEO_SRTP_PARAM": b"\x04", + "AUDIO_SRTP_PARAM": b"\x05", + "VIDEO_SSRC": b"\x06", + "AUDIO_SSRC": b"\x07", } SETUP_STATUS = { - 'SUCCESS': b'\x00', - 'BUSY': b'\x01', - 'ERROR': b'\x02' + "SUCCESS": b"\x00", + "BUSY": b"\x01", + "ERROR": b"\x02", } SETUP_IPV = { - 'IPV4': b'\x00', - 'IPV6': b'\x01' + "IPV4": b"\x00", + "IPV6": b"\x01", } SETUP_ADDR_INFO = { - 'ADDRESS_VER': b'\x01', - 'ADDRESS': b'\x02', - 'VIDEO_RTP_PORT': b'\x03', - 'AUDIO_RTP_PORT': b'\x04' + "ADDRESS_VER": b"\x01", + "ADDRESS": b"\x02", + "VIDEO_RTP_PORT": b"\x03", + "AUDIO_RTP_PORT": b"\x04", } SETUP_SRTP_PARAM = { - 'CRYPTO': b'\x01', - 'MASTER_KEY': b'\x02', - 'MASTER_SALT': b'\x03' + "CRYPTO": b"\x01", + "MASTER_KEY": b"\x02", + "MASTER_SALT": b"\x03", } STREAMING_STATUS = { - 'AVAILABLE': b'\x00', - 'STREAMING': b'\x01', - 'BUSY': b'\x02' + "AVAILABLE": b"\x00", + "STREAMING": b"\x01", + "BUSY": b"\x02", } RTP_CONFIG_TYPES = { - 'CRYPTO': b'\x02' + "CRYPTO": b"\x02", } SRTP_CRYPTO_SUITES = { - 'AES_CM_128_HMAC_SHA1_80': b'\x00', - 'AES_CM_256_HMAC_SHA1_80': b'\x01', - 'NONE': b'\x02' + "AES_CM_128_HMAC_SHA1_80": b"\x00", + "AES_CM_256_HMAC_SHA1_80": b"\x01", + "NONE": b"\x02", } VIDEO_TYPES = { - 'CODEC': b'\x01', - 'CODEC_PARAM': b'\x02', - 'ATTRIBUTES': b'\x03', - 'RTP_PARAM': b'\x04' + "CODEC": b"\x01", + "CODEC_PARAM": b"\x02", + "ATTRIBUTES": b"\x03", + "RTP_PARAM": b"\x04", } VIDEO_CODEC_TYPES = { - 'H264': b'\x00' + "H264": b"\x00", } VIDEO_CODEC_PARAM_TYPES = { - 'PROFILE_ID': b'\x01', - 'LEVEL': b'\x02', - 'PACKETIZATION_MODE': b'\x03', - 'CVO_ENABLED': b'\x04', - 'CVO_ID': b'\x05' + "PROFILE_ID": b"\x01", + "LEVEL": b"\x02", + "PACKETIZATION_MODE": b"\x03", + "CVO_ENABLED": b"\x04", + "CVO_ID": b"\x05", } VIDEO_CODEC_PARAM_CVO_TYPES = { - 'UNSUPPORTED': b'\x01', - 'SUPPORTED': b'\x02' + "UNSUPPORTED": b"\x01", + "SUPPORTED": b"\x02", } VIDEO_CODEC_PARAM_PROFILE_ID_TYPES = { - 'BASELINE': b'\x00', - 'MAIN': b'\x01', - 'HIGH': b'\x02' + "BASELINE": b"\x00", + "MAIN": b"\x01", + "HIGH": b"\x02", } VIDEO_CODEC_PARAM_LEVEL_TYPES = { - 'TYPE3_1': b'\x00', - 'TYPE3_2': b'\x01', - 'TYPE4_0': b'\x02' + "TYPE3_1": b"\x00", + "TYPE3_2": b"\x01", + "TYPE4_0": b"\x02", } VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES = { - 'NON_INTERLEAVED': b'\x00' + "NON_INTERLEAVED": b"\x00", } VIDEO_ATTRIBUTES_TYPES = { - 'IMAGE_WIDTH': b'\x01', - 'IMAGE_HEIGHT': b'\x02', - 'FRAME_RATE': b'\x03' + "IMAGE_WIDTH": b"\x01", + "IMAGE_HEIGHT": b"\x02", + "FRAME_RATE": b"\x03", } -SUPPORTED_VIDEO_CONFIG_TAG = b'\x01' +SUPPORTED_VIDEO_CONFIG_TAG = b"\x01" SELECTED_STREAM_CONFIGURATION_TYPES = { - 'SESSION': b'\x01', - 'VIDEO': b'\x02', - 'AUDIO': b'\x03' + "SESSION": b"\x01", + "VIDEO": b"\x02", + "AUDIO": b"\x03", } RTP_PARAM_TYPES = { - 'PAYLOAD_TYPE': b'\x01', - 'SYNCHRONIZATION_SOURCE': b'\x02', - 'MAX_BIT_RATE': b'\x03', - 'RTCP_SEND_INTERVAL': b'\x04', - 'MAX_MTU': b'\x05', - 'COMFORT_NOISE_PAYLOAD_TYPE': b'\x06' + "PAYLOAD_TYPE": b"\x01", + "SYNCHRONIZATION_SOURCE": b"\x02", + "MAX_BIT_RATE": b"\x03", + "RTCP_SEND_INTERVAL": b"\x04", + "MAX_MTU": b"\x05", + "COMFORT_NOISE_PAYLOAD_TYPE": b"\x06", } AUDIO_TYPES = { - 'CODEC': b'\x01', - 'CODEC_PARAM': b'\x02', - 'RTP_PARAM': b'\x03', - 'COMFORT_NOISE': b'\x04' + "CODEC": b"\x01", + "CODEC_PARAM": b"\x02", + "RTP_PARAM": b"\x03", + "COMFORT_NOISE": b"\x04", } AUDIO_CODEC_TYPES = { - 'PCMU': b'\x00', - 'PCMA': b'\x01', - 'AACELD': b'\x02', - 'OPUS': b'\x03' + "PCMU": b"\x00", + "PCMA": b"\x01", + "AACELD": b"\x02", + "OPUS": b"\x03", } AUDIO_CODEC_PARAM_TYPES = { - 'CHANNEL': b'\x01', - 'BIT_RATE': b'\x02', - 'SAMPLE_RATE': b'\x03', - 'PACKET_TIME': b'\x04' + "CHANNEL": b"\x01", + "BIT_RATE": b"\x02", + "SAMPLE_RATE": b"\x03", + "PACKET_TIME": b"\x04", } AUDIO_CODEC_PARAM_BIT_RATE_TYPES = { - 'VARIABLE': b'\x00', - 'CONSTANT': b'\x01' + "VARIABLE": b"\x00", + "CONSTANT": b"\x01", } AUDIO_CODEC_PARAM_SAMPLE_RATE_TYPES = { - 'KHZ_8': b'\x00', - 'KHZ_16': b'\x01', - 'KHZ_24': b'\x02' + "KHZ_8": b"\x00", + "KHZ_16": b"\x01", + "KHZ_24": b"\x02", } -SUPPORTED_AUDIO_CODECS_TAG = b'\x01' -SUPPORTED_COMFORT_NOISE_TAG = b'\x02' -SUPPORTED_AUDIO_CONFIG_TAG = b'\x02' -SET_CONFIG_REQUEST_TAG = b'\x02' -SESSION_ID = b'\x01' +SUPPORTED_AUDIO_CODECS_TAG = b"\x01" +SUPPORTED_COMFORT_NOISE_TAG = b"\x02" +SUPPORTED_AUDIO_CONFIG_TAG = b"\x02" +SET_CONFIG_REQUEST_TAG = b"\x02" +SESSION_ID = b"\x01" -NO_SRTP = b'\x01\x01\x02\x02\x00\x03\x00' -'''Configuration value for no SRTP.''' +NO_SRTP = b"\x01\x01\x02\x02\x00\x03\x00" +"""Configuration value for no SRTP.""" FFMPEG_CMD = ( - 'ffmpeg -re -f avfoundation -framerate {fps} -i 0:0 -threads 0 ' - '-vcodec libx264 -an -pix_fmt yuv420p -r {fps} -f rawvideo -tune zerolatency ' - '-vf scale={width}:{height} -b:v {v_max_bitrate}k -bufsize {v_max_bitrate}k ' - '-payload_type 99 -ssrc {v_ssrc} -f rtp ' - '-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} ' - 'srtp://{address}:{v_port}?rtcpport={v_port}&' - 'localrtcpport={v_port}&pkt_size=1378' + "ffmpeg -re -f avfoundation -framerate {fps} -i 0:0 -threads 0 " + "-vcodec libx264 -an -pix_fmt yuv420p -r {fps} -f rawvideo -tune zerolatency " + "-vf scale={width}:{height} -b:v {v_max_bitrate}k -bufsize {v_max_bitrate}k " + "-payload_type 99 -ssrc {v_ssrc} -f rtp " + "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} " + "srtp://{address}:{v_port}?rtcpport={v_port}&" + "localrtcpport={v_port}&pkt_size=1378" ) -'''Template for the ffmpeg command.''' +"""Template for the ffmpeg command.""" logger = logging.getLogger(__name__) @@ -242,10 +246,10 @@ def get_supported_rtp_config(support_srtp): :type support_srtp: bool """ if support_srtp: - crypto = SRTP_CRYPTO_SUITES['AES_CM_128_HMAC_SHA1_80'] + crypto = SRTP_CRYPTO_SUITES["AES_CM_128_HMAC_SHA1_80"] else: - crypto = SRTP_CRYPTO_SUITES['NONE'] - return tlv.encode(RTP_CONFIG_TYPES['CRYPTO'], crypto, to_base64=True) + crypto = SRTP_CRYPTO_SUITES["NONE"] + return tlv.encode(RTP_CONFIG_TYPES["CRYPTO"], crypto, to_base64=True) @staticmethod def get_supported_video_stream_config(video_params): @@ -259,31 +263,41 @@ def get_supported_video_stream_config(video_params): :type video_params: dict """ codec_params_tlv = tlv.encode( - VIDEO_CODEC_PARAM_TYPES['PACKETIZATION_MODE'], - VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES['NON_INTERLEAVED']) + VIDEO_CODEC_PARAM_TYPES["PACKETIZATION_MODE"], + VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES["NON_INTERLEAVED"], + ) - codec_params = video_params['codec'] - for profile in codec_params['profiles']: - codec_params_tlv += \ - tlv.encode(VIDEO_CODEC_PARAM_TYPES['PROFILE_ID'], profile) + codec_params = video_params["codec"] + for profile in codec_params["profiles"]: + codec_params_tlv += tlv.encode( + VIDEO_CODEC_PARAM_TYPES["PROFILE_ID"], profile + ) - for level in codec_params['levels']: - codec_params_tlv += \ - tlv.encode(VIDEO_CODEC_PARAM_TYPES['LEVEL'], level) + for level in codec_params["levels"]: + codec_params_tlv += tlv.encode(VIDEO_CODEC_PARAM_TYPES["LEVEL"], level) - attr_tlv = b'' - for resolution in video_params['resolutions']: + attr_tlv = b"" + for resolution in video_params["resolutions"]: res_tlv = tlv.encode( - VIDEO_ATTRIBUTES_TYPES['IMAGE_WIDTH'], struct.pack('= (3, 11): + from asyncio import timeout as async_timeout +else: + from async_timeout import timeout as async_timeout + if TYPE_CHECKING: from .accessory_driver import AccessoryDriver @@ -86,9 +92,9 @@ class HAP_TLV_TAGS: ENCRYPTED_DATA = b"\x05" SEQUENCE_NUM = b"\x06" ERROR_CODE = b"\x07" - PROOF = b"\x0A" - PERMISSIONS = b"\x0B" - SEPARATOR = b"\xFF" + PROOF = b"\x0a" + PERMISSIONS = b"\x0b" + SEPARATOR = b"\xff" class UnprivilegedRequestException(Exception): @@ -97,7 +103,7 @@ class UnprivilegedRequestException(Exception): async def _run_with_timeout(coro, timeout: float) -> bytes: """Run a coroutine with a timeout.""" - async with async_timeout.timeout(timeout): + async with async_timeout(timeout): return await coro diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 75d18355..0fda6995 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -2,6 +2,7 @@ The HAPServerProtocol is a protocol implementation that manages the "TLS" of the connection. """ + import asyncio import logging import time diff --git a/pyhap/iid_manager.py b/pyhap/iid_manager.py index e571342c..c78cdbba 100644 --- a/pyhap/iid_manager.py +++ b/pyhap/iid_manager.py @@ -1,4 +1,5 @@ """Module for the IIDManager class.""" + import logging from typing import TYPE_CHECKING, Dict, Optional, Union diff --git a/pyhap/loader.py b/pyhap/loader.py index 3874db13..be636e5e 100644 --- a/pyhap/loader.py +++ b/pyhap/loader.py @@ -7,6 +7,7 @@ instance of it (as long as it is described in some json file). """ + import logging import orjson diff --git a/pyhap/state.py b/pyhap/state.py index 3503b976..b30f7ffc 100644 --- a/pyhap/state.py +++ b/pyhap/state.py @@ -1,4 +1,5 @@ """Module for `State` class.""" + from typing import Dict, List, Optional, Union from uuid import UUID @@ -29,7 +30,7 @@ def __init__( address: Optional[Union[str, List[str]]] = None, mac=None, pincode=None, - port=None + port=None, ): """Initialize a new object. Create key pair. diff --git a/pyhap/tlv.py b/pyhap/tlv.py index 4d87f5f0..942132d7 100644 --- a/pyhap/tlv.py +++ b/pyhap/tlv.py @@ -1,4 +1,5 @@ """Encodes and decodes Tag-Length-Value (tlv8) data.""" + import struct from typing import Any, Dict @@ -32,7 +33,7 @@ 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:] diff --git a/pyhap/util.py b/pyhap/util.py index 4ee35386..e6cb6164 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -3,14 +3,19 @@ import functools import random import socket +import sys from typing import Awaitable, Set from uuid import UUID -import async_timeout import orjson from .const import BASE_UUID +if sys.version_info >= (3, 11): + from asyncio import timeout as async_timeout +else: + from async_timeout import timeout as async_timeout + ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" HEX_DIGITS = "0123456789ABCDEF" _BACKGROUND_TASKS: Set[asyncio.Task] = set() @@ -139,7 +144,7 @@ async def event_wait(event, timeout): :rtype: bool """ try: - async with async_timeout.timeout(timeout): + async with async_timeout(timeout): await event.wait() except asyncio.TimeoutError: pass diff --git a/pyproject.toml b/pyproject.toml index 4c31e9c5..676bc6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,56 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=77.0"] + +[project] +name = "HAP-python" +license = "Apache-2.0" +description = "HomeKit Accessory Protocol implementation in python" +readme = "README.md" +authors = [{ name = "Ivan Kalchev" }] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "async_timeout;python_version<'3.11'", + "cryptography", + "chacha20poly1305-reuseable", + "orjson>=3.7.2", + "zeroconf>=0.36.2", + "h11", +] +dynamic = ["version"] + +[project.optional-dependencies] +QRCode = [ + "base36", + "pyqrcode", +] + +[project.urls] +"Source" = "https://github.com/ikalchev/HAP-python" +"Bug Reports" = "https://github.com/ikalchev/HAP-python/issues" +"Documentation" = "http://hap-python.readthedocs.io/en/latest/" + +[tool.setuptools.packages.find] +include = ["pyhap*"] + +[tool.setuptools.dynamic] +version = { attr = "pyhap.const.__version__" } + [tool.black] -target-version = ["py35", "py36", "py37", "py38"] exclude = 'generated' [tool.isort] @@ -113,3 +164,4 @@ norecursedirs = [ ".git", "testing_config", ] +asyncio_mode = "auto" diff --git a/requirements_all.txt b/requirements_all.txt index 89560833..fd69f306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,3 +1,4 @@ +async_timeout;python_version<'3.11' base36 cryptography orjson diff --git a/scripts/release b/scripts/release index 64e489a4..8f336bf7 100755 --- a/scripts/release +++ b/scripts/release @@ -11,15 +11,11 @@ if [ -n "$(ls | grep 'build')" ]; then rm -r build/ fi -echo "=====================================" -echo "= Generation source distribution =" -echo "=====================================" -python3 setup.py sdist -echo "====================================" -echo "= Generation build distribution =" -echo "====================================" -python3 setup.py bdist_wheel +echo "==============================================" +echo "= Generate source and build distributions =" +echo "==============================================" +python3 -m build echo "=====================" echo "= Upload to pypi =" diff --git a/setup.cfg b/setup.cfg index 2a2577e8..97094410 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,2 @@ -[tool:pytest] -testpaths = tests - [pycodestyle] max-line-length = 90 diff --git a/setup.py b/setup.py deleted file mode 100644 index 778226ca..00000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -from setuptools import setup - -import pyhap.const as pyhap_const - -NAME = "HAP-python" -DESCRIPTION = "HomeKit Accessory Protocol implementation in python" -URL = "https://github.com/ikalchev/{}".format(NAME) -AUTHOR = "Ivan Kalchev" - - -PROJECT_URLS = { - "Bug Reports": "{}/issues".format(URL), - "Documentation": "http://hap-python.readthedocs.io/en/latest/", - "Source": "{}/tree/master".format(URL), -} - - -MIN_PY_VERSION = ".".join(map(str, pyhap_const.REQUIRED_PYTHON_VER)) - -with open("README.md", "r", encoding="utf-8") as f: - README = f.read() - - -REQUIRES = [ - "async_timeout", - "cryptography", - "chacha20poly1305-reuseable", - "orjson>=3.7.2", - "zeroconf>=0.36.2", - "h11", -] - - -setup( - name=NAME, - version=pyhap_const.__version__, - description=DESCRIPTION, - long_description=README, - long_description_content_type="text/markdown", - url=URL, - packages=["pyhap"], - include_package_data=True, - project_urls=PROJECT_URLS, - python_requires=">={}".format(MIN_PY_VERSION), - install_requires=REQUIRES, - license="Apache-2.0", - license_file="LICENSE", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: Apache Software License", - "Natural Language :: English", - "Operating System :: OS Independent", - "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", - ], - extras_require={ - "QRCode": ["base36", "pyqrcode"], - }, -) diff --git a/tests/conftest.py b/tests/conftest.py index c6c6bedd..4c7bf35f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """Test fictures and mocks.""" import asyncio -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -34,12 +34,10 @@ def driver(async_zeroconf): except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - 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.AccessoryDriver.persist" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), ): yield AccessoryDriver(loop=loop) diff --git a/tests/test_accessory.py b/tests/test_accessory.py index 8e0adfa6..5e766759 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -1,4 +1,5 @@ """Tests for pyhap.accessory.""" + import asyncio from io import StringIO from unittest.mock import patch @@ -29,6 +30,8 @@ class TestAccessory(Accessory): """An accessory that keeps track of if its stopped.""" + __test__ = False + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._stopped = False @@ -536,18 +539,13 @@ def test_to_hap_standalone(mock_driver): } -@pytest.mark.asyncio async def test_bridge_run_stop(): - 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.AsyncZeroconf" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + 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.AsyncZeroconf"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) bridge = Bridge(driver, "Test Bridge") diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 88061ed1..f03761c2 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1,4 +1,5 @@ """Tests for pyhap.accessory_driver.""" + import asyncio from concurrent.futures import ThreadPoolExecutor import tempfile @@ -122,8 +123,9 @@ def test_persist_cannot_write(async_zeroconf): def test_external_zeroconf(): zeroconf = MagicMock() - with patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + with ( + patch("pyhap.accessory_driver.HAPServer"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), ): driver = AccessoryDriver(port=51234, async_zeroconf_instance=zeroconf) assert driver.advertiser == zeroconf @@ -131,8 +133,9 @@ def test_external_zeroconf(): def test_advertised_address(): zeroconf = MagicMock() - with patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + with ( + patch("pyhap.accessory_driver.HAPServer"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), ): driver = AccessoryDriver( port=51234, @@ -553,7 +556,8 @@ def setup_message(self): acc = Acc(driver, "TestAcc") driver.add_accessory(acc) - driver.start() + with patch.object(driver.loop, "close"): + driver.start() def test_accessory_level_callbacks(driver: AccessoryDriver): @@ -763,16 +767,12 @@ def test_accessory_level_callbacks_with_a_failure(driver: AccessoryDriver): } -@pytest.mark.asyncio async def test_start_stop_sync_acc(async_zeroconf): - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) run_event = asyncio.Event() @@ -795,17 +795,13 @@ def setup_message(self): assert not driver.loop.is_closed() -@pytest.mark.asyncio async def test_start_stop_async_acc(async_zeroconf): """Verify run_at_interval closes the driver.""" - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver( loop=asyncio.get_event_loop(), interface_choice=InterfaceChoice.Default @@ -858,16 +854,12 @@ def setup_message(self): assert not driver.loop.is_closed() -@pytest.mark.asyncio async def test_start_from_async_stop_from_executor(async_zeroconf): - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) run_event = asyncio.Event() @@ -1041,17 +1033,13 @@ def test_mdns_name_sanity( assert mdns_info.server == mdns_server -@pytest.mark.asyncio async def test_start_service_and_update_config(async_zeroconf): """Test starting service and updating the config.""" - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) acc = Accessory(driver, "TestAcc") @@ -1078,12 +1066,13 @@ def test_call_add_job_with_none(driver): 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.AccessoryDriver.persist" - ), patch("pyhap.accessory_driver.AccessoryDriver.load"): + with ( + patch("pyhap.accessory_driver.HAPServer"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), + ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) called = False @@ -1099,12 +1088,13 @@ async def coro_test(): assert called is True -@pytest.mark.asyncio async def test_call_async_add_job_with_callback(driver, async_zeroconf): """Test calling async_add_job with a coroutine.""" - with patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" - ), patch("pyhap.accessory_driver.AccessoryDriver.load"): + with ( + patch("pyhap.accessory_driver.HAPServer"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), + ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) called = False @@ -1119,16 +1109,12 @@ def callback_test(): assert called is True -@pytest.mark.asyncio async def test_bridge_with_multiple_async_run_at_interval_accessories(async_zeroconf): - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) bridge = Bridge(driver, "mybridge") @@ -1149,16 +1135,12 @@ async def test_bridge_with_multiple_async_run_at_interval_accessories(async_zero assert acc3.counter > 2 -@pytest.mark.asyncio async def test_bridge_with_multiple_sync_run_at_interval_accessories(async_zeroconf): - 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.AccessoryDriver.persist" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=asyncio.get_event_loop()) bridge = Bridge(driver, "mybridge") diff --git a/tests/test_camera.py b/tests/test_camera.py index 726a37ee..9adcfc87 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,4 +1,5 @@ """Tests for pyhap.camera.""" + from unittest.mock import Mock, patch from uuid import UUID @@ -86,7 +87,8 @@ def test_setup_endpoints(mock_driver): def test_set_selected_stream_start_stop(mock_driver): - """Test starting a stream request.""" + """Test starting a stream request.""" # noqa: D202 + # mocks for asyncio.Process async def communicate(): return (None, "stderr") diff --git a/tests/test_characteristic.py b/tests/test_characteristic.py index 12d32713..52e44173 100644 --- a/tests/test_characteristic.py +++ b/tests/test_characteristic.py @@ -1,4 +1,5 @@ """Tests for pyhap.characteristic.""" + from unittest.mock import ANY, MagicMock, Mock, patch from uuid import uuid1 diff --git a/tests/test_encoder.py b/tests/test_encoder.py index 1267017b..b4ac5222 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -1,4 +1,5 @@ """Tests for pyhap.encoder.""" + import json import tempfile import uuid diff --git a/tests/test_hap_crypto.py b/tests/test_hap_crypto.py index c6404656..924c5eef 100644 --- a/tests/test_hap_crypto.py +++ b/tests/test_hap_crypto.py @@ -1,6 +1,5 @@ """Tests for the HAPCrypto.""" - from pyhap import hap_crypto diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 75cd0d4f..963cffa1 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -32,7 +32,7 @@ def test_response(): assert "500" in str(response) -def test_list_pairings_unencrypted(driver: AccessoryDriver): +async def test_list_pairings_unencrypted(driver: AccessoryDriver): """Verify an unencrypted list pairings request fails.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -57,7 +57,7 @@ def test_list_pairings_unencrypted(driver: AccessoryDriver): } -def test_list_pairings(driver: AccessoryDriver): +async def test_list_pairings(driver: AccessoryDriver): """Verify an encrypted list pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -84,7 +84,7 @@ def test_list_pairings(driver: AccessoryDriver): } -def test_list_pairings_multiple(driver: AccessoryDriver): +async def test_list_pairings_multiple(driver: AccessoryDriver): """Verify an encrypted list pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -117,7 +117,7 @@ def test_list_pairings_multiple(driver: AccessoryDriver): } -def test_add_pairing_admin(driver: AccessoryDriver): +async def test_add_pairing_admin(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -148,7 +148,7 @@ def test_add_pairing_admin(driver: AccessoryDriver): assert driver.state.is_admin(CLIENT2_UUID) -def test_add_pairing_user(driver: AccessoryDriver): +async def test_add_pairing_user(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -221,7 +221,7 @@ def test_add_pairing_user(driver: AccessoryDriver): assert not driver.state.is_admin(CLIENT2_UUID) -def test_remove_pairing(driver: AccessoryDriver): +async def test_remove_pairing(driver: AccessoryDriver): """Verify an encrypted remove pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -272,7 +272,7 @@ def test_remove_pairing(driver: AccessoryDriver): assert driver.state.paired is False -def test_non_admin_pairings_request(driver: AccessoryDriver): +async def test_non_admin_pairings_request(driver: AccessoryDriver): """Verify only admins can access pairings.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -296,7 +296,7 @@ def test_non_admin_pairings_request(driver: AccessoryDriver): } -def test_invalid_pairings_request(driver: AccessoryDriver): +async def test_invalid_pairings_request(driver: AccessoryDriver): """Verify an encrypted invalid pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -317,7 +317,7 @@ def test_invalid_pairings_request(driver: AccessoryDriver): handler.handle_pairings() -def test_pair_verify_one(driver: AccessoryDriver): +async def test_pair_verify_one(driver: AccessoryDriver): """Verify an unencrypted pair verify one.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -369,7 +369,7 @@ def test_pair_verify_one_not_paired(driver: AccessoryDriver): } -def test_pair_verify_two_invalid_state(driver: AccessoryDriver): +async def test_pair_verify_two_invalid_state(driver: AccessoryDriver): """Verify an unencrypted pair verify two.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -413,7 +413,7 @@ def test_pair_verify_two_invalid_state(driver: AccessoryDriver): } -def test_pair_verify_two_missing_signature(driver: AccessoryDriver): +async def test_pair_verify_two_missing_signature(driver: AccessoryDriver): """Verify a pair verify two with a missing signature.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -466,7 +466,7 @@ def test_pair_verify_two_missing_signature(driver: AccessoryDriver): } -def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver): +async def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver): """Verify a pair verify two populated missing raw bytes.""" driver.add_accessory(Accessory(driver, "TestAcc")) client_private_key = ed25519.Ed25519PrivateKey.generate() @@ -549,7 +549,7 @@ def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver) assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES -def test_pair_verify_two_success(driver: AccessoryDriver): +async def test_pair_verify_two_success(driver: AccessoryDriver): """Verify a pair verify two.""" driver.add_accessory(Accessory(driver, "TestAcc")) client_private_key = ed25519.Ed25519PrivateKey.generate() @@ -627,7 +627,7 @@ def test_pair_verify_two_success(driver: AccessoryDriver): assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES -def test_invalid_pairing_request(driver: AccessoryDriver): +async def test_invalid_pairing_request(driver: AccessoryDriver): """Verify an unencrypted pair verify with an invalid sequence fails.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -926,7 +926,7 @@ def test_handle_snapshot_encrypted_non_existant_accessory(driver: AccessoryDrive handler.handle_resource() -def test_attempt_to_pair_when_already_paired(driver: AccessoryDriver): +async def test_attempt_to_pair_when_already_paired(driver: AccessoryDriver): """Verify we respond with unavailable if already paired.""" driver.add_accessory(Accessory(driver, "TestAcc")) diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index ca121083..fb88ebbf 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -1,10 +1,10 @@ """Tests for the HAPServerProtocol.""" + import asyncio import time from unittest.mock import MagicMock, Mock, patch from cryptography.exceptions import InvalidTag -import pytest from pyhap import hap_handler, hap_protocol from pyhap.accessory import Accessory, Bridge @@ -388,7 +388,6 @@ def test_http_11_keep_alive(driver): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_connection_closed(driver): """Test camera snapshot when the other side closes the connection.""" loop = MagicMock() @@ -447,7 +446,6 @@ def test_camera_snapshot_without_snapshot_support(driver): assert b"-70402" in b"".join(writelines.call_args_list[0][0]) -@pytest.mark.asyncio async def test_camera_snapshot_works_sync(driver): """Test camera snapshot works if there is support for it.""" loop = MagicMock() @@ -480,7 +478,6 @@ def _get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_works_async(driver): """Test camera snapshot works if there is support for it.""" loop = MagicMock() @@ -513,7 +510,6 @@ async def _async_get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_timeout_async(driver): """Test camera snapshot timeout is handled.""" loop = MagicMock() @@ -535,9 +531,10 @@ async def _async_get_snapshot(*_): hap_proto.hap_crypto = MockHAPCrypto() hap_proto.handler.is_encrypted = True - with patch.object(hap_handler, "RESPONSE_TIMEOUT", 0.1), patch.object( - hap_proto.transport, "writelines" - ) as writelines: + with ( + patch.object(hap_handler, "RESPONSE_TIMEOUT", 0.1), + patch.object(hap_proto.transport, "writelines") as writelines, + ): hap_proto.data_received( b'POST /resource HTTP/1.1\r\nHost: HASS\\032Bridge\\032BROZ\\0323BF435._hap._tcp.local\r\nContent-Length: 79\r\nContent-Type: application/hap+json\r\n\r\n{"image-height":360,"resource-type":"image","image-width":640,"aid":1411620844}' # pylint: disable=line-too-long ) @@ -568,8 +565,9 @@ def _make_response(*_): response.shared_key = b"newkey" return response - with patch.object(hap_proto.transport, "writelines"), patch.object( - hap_proto.handler, "dispatch", _make_response + with ( + patch.object(hap_proto.transport, "writelines"), + patch.object(hap_proto.handler, "dispatch", _make_response), ): 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 @@ -580,7 +578,6 @@ def _make_response(*_): hap_proto.close() -@pytest.mark.asyncio async def test_pairing_changed(driver): """Test we update mdns when the pairing changes.""" loop = MagicMock() @@ -606,8 +603,9 @@ def _make_response(*_): response.pairing_changed = True return response - with patch.object(hap_proto.transport, "write"), patch.object( - hap_proto.handler, "dispatch", _make_response + with ( + patch.object(hap_proto.transport, "write"), + patch.object(hap_proto.handler, "dispatch", _make_response), ): 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 @@ -618,7 +616,6 @@ def _make_response(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_throws_an_exception(driver): """Test camera snapshot that throws an exception.""" loop = MagicMock() @@ -654,7 +651,6 @@ async def _async_get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_times_out(driver): """Test camera snapshot times out.""" loop = MagicMock() @@ -690,7 +686,6 @@ def _get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_missing_accessory(driver): """Test camera snapshot that throws an exception.""" loop = MagicMock() @@ -717,7 +712,6 @@ async def test_camera_snapshot_missing_accessory(driver): 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() @@ -728,9 +722,11 @@ async def test_idle_timeout(driver): 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: + 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 ) @@ -739,7 +735,6 @@ async def test_idle_timeout(driver): 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() @@ -750,9 +745,10 @@ async def test_does_not_timeout(driver): 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: + 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 ) diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index 46c8884d..88ee55c9 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -3,15 +3,12 @@ import asyncio from unittest.mock import MagicMock, patch -import pytest - from pyhap import hap_server from pyhap.accessory import Accessory from pyhap.accessory_driver import AccessoryDriver from pyhap.hap_protocol import HAPServerProtocol -@pytest.mark.asyncio async def test_we_can_start_stop(driver): """Test we can start and stop.""" loop = asyncio.get_event_loop() @@ -26,12 +23,12 @@ async def test_we_can_start_stop(driver): 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.AsyncZeroconf"), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + with ( + patch("pyhap.accessory_driver.AsyncZeroconf"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), ): driver = AccessoryDriver(loop=loop) @@ -52,17 +49,17 @@ async def test_we_can_connect(): writer.close() -@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.AsyncZeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( - "pyhap.accessory_driver.AccessoryDriver.load" + with ( + patch.object(hap_server, "IDLE_CONNECTION_CHECK_INTERVAL_SECONDS", 0), + patch("pyhap.accessory_driver.AsyncZeroconf"), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + patch("pyhap.accessory_driver.AccessoryDriver.load"), ): driver = AccessoryDriver(loop=loop) server = hap_server.HAPServer(addr_info, driver) @@ -79,7 +76,6 @@ async def test_idle_connection_cleanup(): server.async_stop() -@pytest.mark.asyncio async def test_push_event(driver): """Test we can create and send an event.""" addr_info = ("1.2.3.4", 1234) @@ -151,7 +147,6 @@ def _save_event(hap_event): ] -@pytest.mark.asyncio async def test_push_event_overwrites_old_pending_events(driver): """Test push event overwrites old events in the event queue. diff --git a/tests/test_iid_manager.py b/tests/test_iid_manager.py index 399f7456..a7ae64a4 100644 --- a/tests/test_iid_manager.py +++ b/tests/test_iid_manager.py @@ -1,4 +1,5 @@ """Tests for pyhap.iid_manager module.""" + from unittest.mock import Mock from pyhap.iid_manager import IIDManager diff --git a/tests/test_loader.py b/tests/test_loader.py index 8c1f08c1..b575987b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,4 +1,5 @@ """Tests for pyhap.loader.""" + import pytest from pyhap import CHARACTERISTICS_FILE, SERVICES_FILE diff --git a/tests/test_service.py b/tests/test_service.py index 83852c73..de25be6b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,4 +1,5 @@ """Tests for pyhap.service.""" + from unittest.mock import Mock, call, patch from uuid import uuid1 @@ -76,9 +77,10 @@ def test_configure_char(): service.configure_char("Char not found") assert service.configure_char("Char 1") == chars[0] - with patch(pyhap_char + ".override_properties") as mock_override_prop, patch( - pyhap_char + ".set_value" - ) as mock_set_value: + with ( + patch(pyhap_char + ".override_properties") as mock_override_prop, + patch(pyhap_char + ".set_value") as mock_set_value, + ): service.configure_char("Char 1") mock_override_prop.assert_not_called() mock_set_value.assert_not_called() @@ -139,9 +141,10 @@ def test_to_HAP(): service = Service(uuid, "Test Service") service.characteristics = get_chars() - with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( - service, "broker" - ) as mock_broker: + with ( + patch(pyhap_char_to_HAP) as mock_char_HAP, + patch.object(service, "broker") as mock_broker, + ): mock_iid = mock_broker.iid_manager.get_iid mock_iid.return_value = 2 mock_char_HAP.side_effect = ("Char 1", "Char 2") @@ -165,9 +168,11 @@ def test_linked_service_to_HAP(): service.broker = Mock() service.add_linked_service(linked_service) service.characteristics = get_chars() - with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( - service, "broker" - ) as mock_broker, patch.object(linked_service, "broker") as mock_linked_broker: + with ( + patch(pyhap_char_to_HAP) as mock_char_HAP, + patch.object(service, "broker") as mock_broker, + patch.object(linked_service, "broker") as mock_linked_broker, + ): mock_iid = mock_broker.iid_manager.get_iid mock_iid.return_value = 2 mock_linked_iid = mock_linked_broker.iid_manager.get_iid @@ -199,9 +204,10 @@ def test_is_primary_service_to_HAP(): service = Service(uuid, "Test Service") service.characteristics = get_chars() service.is_primary_service = True - with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( - service, "broker" - ) as mock_broker: + with ( + patch(pyhap_char_to_HAP) as mock_char_HAP, + patch.object(service, "broker") as mock_broker, + ): mock_iid = mock_broker.iid_manager.get_iid mock_iid.return_value = 2 mock_char_HAP.side_effect = ("Char 1", "Char 2") diff --git a/tests/test_state.py b/tests/test_state.py index b7da0723..9f92c84a 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,4 +1,5 @@ """Test for pyhap.state.""" + from unittest.mock import patch from uuid import UUID @@ -26,14 +27,16 @@ def test_setup(): private_key = ed25519.Ed25519PrivateKey.generate() - with patch("pyhap.util.get_local_address") as mock_local_addr, patch( - "pyhap.util.generate_mac" - ) as mock_gen_mac, patch("pyhap.util.generate_pincode") as mock_gen_pincode, patch( - "pyhap.util.generate_setup_id" - ) as mock_gen_setup_id, patch( - "cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate", - return_value=private_key, - ) as mock_create_keypair: + with ( + patch("pyhap.util.get_local_address") as mock_local_addr, + patch("pyhap.util.generate_mac") as mock_gen_mac, + patch("pyhap.util.generate_pincode") as mock_gen_pincode, + patch("pyhap.util.generate_setup_id") as mock_gen_setup_id, + patch( + "cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate", + return_value=private_key, + ) as mock_create_keypair, + ): state = State(address=addr, mac=mac, pincode=pin, port=port) assert not mock_local_addr.called assert not mock_gen_mac.called @@ -57,9 +60,12 @@ def test_setup(): def test_pairing_remove_last_admin(): """Test if pairing methods work.""" - with patch("pyhap.util.get_local_address"), patch("pyhap.util.generate_mac"), patch( - "pyhap.util.generate_pincode" - ), patch("pyhap.util.generate_setup_id"): + with ( + patch("pyhap.util.get_local_address"), + patch("pyhap.util.generate_mac"), + patch("pyhap.util.generate_pincode"), + patch("pyhap.util.generate_setup_id"), + ): state = State() assert not state.paired @@ -90,9 +96,12 @@ def test_pairing_remove_last_admin(): def test_pairing_two_admins(): """Test if pairing methods work.""" - with patch("pyhap.util.get_local_address"), patch("pyhap.util.generate_mac"), patch( - "pyhap.util.generate_pincode" - ), patch("pyhap.util.generate_setup_id"): + with ( + patch("pyhap.util.get_local_address"), + patch("pyhap.util.generate_mac"), + patch("pyhap.util.generate_pincode"), + patch("pyhap.util.generate_setup_id"), + ): state = State() assert not state.paired diff --git a/tests/test_util.py b/tests/test_util.py index 2b787576..e19fbc15 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,5 @@ """Test for pyhap.util.""" + import functools from uuid import UUID diff --git a/tox.ini b/tox.ini index c8ef3f22..4142281e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,14 @@ [tox] -envlist = py35, py36, py37, py38, py39, py310, py311, py312, docs, lint, pylint, bandit +envlist = py39, py310, py311, py312, py313, docs, lint, pylint, bandit skip_missing_interpreters = True [gh-actions] python = - 3.5: py36 - 3.6: py36 - 3.7: py37 - 3.8: py38, mypy 3.9: py39, mypy 3.10: py310, mypy 3.11: py311, mypy 3.12: py312, mypy + 3.13: py313, mypy [testenv] deps = @@ -28,7 +25,7 @@ commands = pytest --timeout=2 --cov --cov-report=xml {posargs} [testenv:temperature] -basepython = python3.6 +basepython = python3.12 deps = -r{toxinidir}/requirements_all.txt commands = From dce516d8e99612d8ae4802a62d23213b48b80ddd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Aug 2025 11:59:26 +0200 Subject: [PATCH 12/13] Replace deprecated asyncio.iscoroutinefunction (#486) --- pyhap/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyhap/util.py b/pyhap/util.py index e6cb6164..d8b43c5a 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -1,6 +1,7 @@ import asyncio import base64 import functools +import inspect import random import socket import sys @@ -41,7 +42,7 @@ def iscoro(func): """ if isinstance(func, functools.partial): func = func.func - return asyncio.iscoroutinefunction(func) + return inspect.iscoroutinefunction(func) def get_local_address() -> str: From 117a0ca2f54804dc221e24b21d518104b739ca23 Mon Sep 17 00:00:00 2001 From: Ivan Kalchev Date: Mon, 25 Aug 2025 14:46:00 +0300 Subject: [PATCH 13/13] Prep to release v5.0.0 --- CHANGELOG.md | 5 +++++ pyhap/const.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1066948f..8f749eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Sections ### Developers --> +## [5.0.0] - 2025-08-25 + +- Modernize packaging + drop support for 3.7 and 3.8. + [#487](https://github.com/ikalchev/HAP-python/pull/487) + ## [4.9.2] - 2024-11-03 - Implement zerocopy writes for the encrypted protocol. [#476](https://github.com/ikalchev/HAP-python/pull/476) diff --git a/pyhap/const.py b/pyhap/const.py index e39d92d3..73492d54 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,8 +1,8 @@ """This module contains constants used by other modules.""" -MAJOR_VERSION = 4 -MINOR_VERSION = 9 -PATCH_VERSION = 2 +MAJOR_VERSION = 5 +MINOR_VERSION = 0 +PATCH_VERSION = 0 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"